nightshift-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,360 @@
1
+ """The Codex adapter.
2
+
3
+ Read-only is enforced by Codex's own sandbox — Seatbelt on macOS, Landlock plus
4
+ seccomp on Linux, restricted tokens on Windows. That is a stronger guarantee
5
+ than the tool allowlist the Claude Code adapter relies on: an allowlist works
6
+ because the agent honours its own tool gating, whereas here the kernel refuses
7
+ the write. A bug in the model cannot touch the disk.
8
+
9
+ The flags below were read off the published `codex exec` reference rather than
10
+ assumed, and two of them are load-bearing in ways worth stating:
11
+
12
+ - ``--sandbox read-only`` is passed explicitly even though a fresh install
13
+ already defaults to it, because the reference is explicit that ``--sandbox``
14
+ "defaults to configuration settings". A user's ``~/.codex/config.toml``
15
+ setting ``workspace-write`` would otherwise silently become nightshift's
16
+ default too. "It defaults to read-only" is a fact about a fresh machine, not
17
+ a guarantee, and this product's whole claim rests on the difference.
18
+
19
+ - ``--ignore-user-config`` is belt-and-braces of the same kind as the Claude
20
+ adapter's denylist — the explicit flags above should already settle it. It
21
+ earns its place on a second count: MCP servers are configured in that same
22
+ ``config.toml``, and an MCP tool is not a shell command, so it is not obvious
23
+ that the filesystem sandbox constrains one at all. Refusing to load the file
24
+ means there are no MCP servers to find out about. Authentication is
25
+ unaffected — the reference states auth still resolves via ``CODEX_HOME``.
26
+
27
+ The cost of that second flag is real and worth knowing: a user's configured
28
+ model choice is ignored too, so runs use Codex's default model.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import shutil
35
+ import subprocess
36
+ from datetime import datetime
37
+ from pathlib import Path
38
+
39
+ from nightshift import sessions
40
+ from nightshift.adapters._process import (
41
+ clip,
42
+ emit,
43
+ stream_ndjson,
44
+ summarize,
45
+ )
46
+ from nightshift.adapters.base import Availability, Event, OnEvent, RunResult
47
+
48
+ #: The only sandbox nightshift will ever ask for. Not a default — an assertion.
49
+ SANDBOX = "read-only"
50
+
51
+ #: Never pause for a human: there isn't one, and a run that could ask for
52
+ #: approval is a run that could be granted an escalation out of the sandbox.
53
+ APPROVAL = "never"
54
+
55
+ #: Where Codex records its sessions. ``CODEX_HOME`` relocates the whole tree.
56
+ CODEX_HOME = "~/.codex"
57
+
58
+ _READ_ONLY_PREAMBLE = (
59
+ "You are running unattended as part of an automated read-only review.\n"
60
+ "You are in a read-only sandbox: you cannot write, edit, or delete files, "
61
+ "and nothing you do can change this repository. Do not attempt it, and do "
62
+ "not ask questions. Inspect the repository and report findings only.\n\n"
63
+ )
64
+
65
+
66
+ def _first_token(command: str) -> tuple[str, str]:
67
+ """Split a shell command into (program, rest) for display.
68
+
69
+ ``rg -n foo src/`` reads better as ``rg(-n foo src/)`` than as
70
+ ``Shell(rg -n foo src/)``, and lines up with how the Claude adapter names
71
+ the tool it is running.
72
+ """
73
+ command = " ".join(str(command or "").split())
74
+ if not command:
75
+ return "shell", ""
76
+ head, _, rest = command.partition(" ")
77
+ return head, rest
78
+
79
+
80
+ class _Collector:
81
+ """Turns Codex's NDJSON event stream into findings, events, and alarms.
82
+
83
+ One of these per run. It is the ``on_line`` sink for ``stream_ndjson``, and
84
+ it is deliberately the only place that knows Codex's event schema.
85
+ """
86
+
87
+ def __init__(self, project_dir: Path, on_event: OnEvent | None = None):
88
+ self.project_dir = project_dir
89
+ self.on_event = on_event
90
+ #: Every ``agent_message`` seen, in order.
91
+ self.messages: list[str] = []
92
+ #: Paths Codex reported it *changed*. Must stay empty; see :meth:`_item`.
93
+ self.wrote: list[str] = []
94
+ #: A turn- or stream-level error message, preferred over raw stderr.
95
+ self.error = ""
96
+ self._claimed = False
97
+
98
+ @property
99
+ def findings(self) -> str:
100
+ """The final assistant message.
101
+
102
+ Codex's own ``--output-last-message`` treats the last one as *the*
103
+ answer, so we do too. Joining every message instead would file the
104
+ model's running narration into the digest alongside the findings.
105
+ """
106
+ return self.messages[-1] if self.messages else ""
107
+
108
+ def _emit(self, event: Event) -> None:
109
+ if self.on_event is not None:
110
+ emit(self.on_event, event)
111
+
112
+ def __call__(self, payload: dict) -> None:
113
+ kind = payload.get("type")
114
+
115
+ if kind == "thread.started":
116
+ thread_id = str(payload.get("thread_id") or "")
117
+ if thread_id and not self._claimed:
118
+ # Claim on sight, not at the end: a run killed at the deadline
119
+ # still left a session file behind, and an unclaimed session is
120
+ # one last_human_use later mistakes for a human's.
121
+ sessions.record(thread_id)
122
+ self._claimed = True
123
+ self._emit(Event("start", text=str(self.project_dir)))
124
+
125
+ elif kind == "turn.failed":
126
+ error = payload.get("error")
127
+ message = error.get("message") if isinstance(error, dict) else None
128
+ self.error = str(message or "the turn failed")
129
+ self._emit(Event("error", text=self.error))
130
+
131
+ elif kind == "error":
132
+ self.error = str(payload.get("message") or "the run reported an error")
133
+ self._emit(Event("error", text=self.error))
134
+
135
+ elif kind in ("item.started", "item.completed"):
136
+ item = payload.get("item")
137
+ if isinstance(item, dict):
138
+ self._item(kind, item)
139
+
140
+ def _item(self, kind: str, item: dict) -> None:
141
+ item_type = item.get("type")
142
+ done = kind == "item.completed"
143
+
144
+ if item_type == "agent_message" and done:
145
+ text = str(item.get("text") or "").strip()
146
+ if text:
147
+ self.messages.append(text)
148
+ self._emit(Event("text", text=text))
149
+
150
+ elif item_type == "reasoning" and done:
151
+ self._emit(Event("thinking"))
152
+
153
+ elif item_type == "command_execution":
154
+ if done:
155
+ self._emit(Event("tool_result", text=summarize(item.get("aggregated_output"))))
156
+ else:
157
+ program, rest = _first_token(str(item.get("command") or ""))
158
+ self._emit(Event("tool", tool=program, detail=clip(rest, 60)))
159
+
160
+ elif item_type == "file_change" and done:
161
+ # The tripwire. A read-only sandbox cannot let this happen, so if it
162
+ # does, either the sandbox did not apply or it did not hold — and
163
+ # every claim nightshift makes about this provider is void for this
164
+ # run. `status` matters: a *failed* file_change is the sandbox doing
165
+ # its job and refusing a write, which is not an alarm.
166
+ if str(item.get("status") or "") != "completed":
167
+ return
168
+ changes = item.get("changes")
169
+ paths = [
170
+ str(c.get("path"))
171
+ for c in (changes if isinstance(changes, list) else [])
172
+ if isinstance(c, dict) and c.get("path")
173
+ ]
174
+ self.wrote.extend(paths or ["<unnamed file>"])
175
+ self._emit(Event("error", text=f"file changed: {', '.join(self.wrote)}"))
176
+
177
+ elif item_type == "mcp_tool_call" and not done:
178
+ # --ignore-user-config means no MCP servers are configured, so this
179
+ # should never arrive. Render it rather than drop it: a tool we did
180
+ # not expect is worth seeing in the live view.
181
+ server = str(item.get("server") or "?")
182
+ self._emit(Event("tool", tool=f"{server}:{item.get('tool') or '?'}"))
183
+
184
+ elif item_type == "web_search" and done:
185
+ self._emit(Event("tool", tool="web_search", detail=clip(item.get("query"), 60)))
186
+
187
+ elif item_type == "error" and done:
188
+ self._emit(Event("error", text=str(item.get("message") or "")))
189
+
190
+
191
+ class CodexAdapter:
192
+ name = "codex"
193
+
194
+ def __init__(self, binary: str = "codex"):
195
+ self.binary = binary
196
+
197
+ # ---- availability -------------------------------------------------
198
+
199
+ def _which(self) -> str | None:
200
+ return shutil.which(self.binary)
201
+
202
+ def availability(self) -> Availability:
203
+ path = self._which()
204
+ if path is None:
205
+ return Availability(
206
+ ok=False,
207
+ reason=f"`{self.binary}` is not on PATH — install Codex, see "
208
+ f"https://developers.openai.com/codex",
209
+ )
210
+ try:
211
+ proc = subprocess.run(
212
+ [self.binary, "--version"],
213
+ capture_output=True,
214
+ text=True,
215
+ timeout=20,
216
+ check=False,
217
+ )
218
+ except (OSError, subprocess.SubprocessError) as exc:
219
+ return Availability(ok=False, reason=f"`{self.binary} --version` failed: {exc}")
220
+ if proc.returncode != 0:
221
+ detail = (proc.stderr or proc.stdout or "").strip().splitlines()
222
+ first = detail[0] if detail else f"exit {proc.returncode}"
223
+ return Availability(ok=False, reason=f"`{self.binary} --version` failed: {first}")
224
+ return Availability(ok=True, reason=(proc.stdout or "").strip())
225
+
226
+ def available(self) -> bool:
227
+ return self.availability().ok
228
+
229
+ # ---- idle detection -----------------------------------------------
230
+
231
+ def _sessions_dir(self) -> Path:
232
+ home = os.environ.get("CODEX_HOME") or CODEX_HOME
233
+ return Path(os.path.expanduser(home)) / "sessions"
234
+
235
+ def last_human_use(self) -> datetime | None:
236
+ """Newest mtime under ``$CODEX_HOME/sessions`` that was not ours.
237
+
238
+ ``None`` when the directory is absent: a Codex that has never run reads
239
+ as idle, not as busy.
240
+
241
+ Our own runs write session files here too, so they are skipped — count
242
+ them and every run would gate the next one, and nightshift would put
243
+ itself to sleep. Codex names these ``rollout-<timestamp>-<thread_id>``
244
+ rather than after the id alone, so this matches on *containment* where
245
+ the Claude adapter can compare the stem outright.
246
+
247
+ Only files count. A directory's mtime bumps when anything inside it is
248
+ written, including our own session, so it cannot be attributed to a
249
+ human once nightshift shares the tree.
250
+ """
251
+ root = self._sessions_dir()
252
+ if not root.is_dir():
253
+ return None
254
+ mine = sessions.ours()
255
+ newest: float | None = None
256
+ try:
257
+ for path in root.rglob("*"):
258
+ try:
259
+ if not path.is_file():
260
+ continue
261
+ if any(sid and sid in path.name for sid in mine):
262
+ continue
263
+ mtime = path.stat().st_mtime
264
+ except OSError:
265
+ continue
266
+ if newest is None or mtime > newest:
267
+ newest = mtime
268
+ except OSError:
269
+ return None
270
+ if newest is None:
271
+ return None
272
+ return datetime.fromtimestamp(newest)
273
+
274
+ # ---- running ------------------------------------------------------
275
+
276
+ def command(self, prompt: str) -> list[str]:
277
+ return [
278
+ self.binary,
279
+ "exec",
280
+ "--sandbox",
281
+ SANDBOX,
282
+ "--ask-for-approval",
283
+ APPROVAL,
284
+ # nightshift registers directories, not repositories; codex exec
285
+ # refuses to run outside a git repo without this. The check exists
286
+ # to protect uncommitted work from edits, which is not a risk we
287
+ # have.
288
+ "--skip-git-repo-check",
289
+ "--ignore-user-config",
290
+ # Project-local execpolicy rules cannot widen an OS sandbox, but
291
+ # they can make one project behave unlike another. A review that
292
+ # depends on a file in the repo being reviewed is not one we want.
293
+ "--ignore-rules",
294
+ "--json",
295
+ _READ_ONLY_PREAMBLE + prompt,
296
+ ]
297
+
298
+ def run(
299
+ self,
300
+ prompt: str,
301
+ project_dir: Path,
302
+ timeout_s: int,
303
+ on_event: OnEvent | None = None,
304
+ ) -> RunResult:
305
+ """One read-only review.
306
+
307
+ Unlike the Claude adapter there is no separate buffered path: NDJSON is
308
+ the only output format worth parsing, and ``stream_ndjson`` is the only
309
+ implementation that reliably kills the process tree at the deadline.
310
+ An unattended run is the same run with nobody listening.
311
+ """
312
+ started = datetime.now()
313
+
314
+ def finish(status: str, findings_md: str, detail: str = "") -> RunResult:
315
+ return RunResult(
316
+ provider=self.name,
317
+ project=project_dir.name,
318
+ task="",
319
+ status=status, # type: ignore[arg-type]
320
+ findings_md=findings_md,
321
+ started_at=started,
322
+ duration_s=(datetime.now() - started).total_seconds(),
323
+ detail=detail,
324
+ )
325
+
326
+ if not project_dir.is_dir():
327
+ return finish("failed", "", f"project path does not exist: {project_dir}")
328
+
329
+ collector = _Collector(project_dir, on_event)
330
+ try:
331
+ outcome = stream_ndjson(self.command(prompt), project_dir, timeout_s, collector)
332
+ except FileNotFoundError:
333
+ return finish("failed", "", f"`{self.binary}` is not on PATH")
334
+ except OSError as exc:
335
+ return finish("failed", "", f"could not start `{self.binary}`: {exc}")
336
+
337
+ findings = collector.findings
338
+
339
+ if collector.wrote:
340
+ # Before anything else, including the exit code: a run that changed
341
+ # files broke the promise the provider is here to keep, and "it also
342
+ # exited 0" is not the headline. Fail loudly and keep the output, so
343
+ # the digest carries the evidence.
344
+ return finish(
345
+ "failed",
346
+ findings,
347
+ f"sandbox breach — codex reported changing {', '.join(collector.wrote)}; "
348
+ "refusing to treat this run as read-only",
349
+ )
350
+
351
+ if outcome.timed_out:
352
+ return finish("timeout", findings, f"no output after {timeout_s}s")
353
+
354
+ if outcome.returncode != 0:
355
+ return finish("failed", findings, collector.error or outcome.stderr_head)
356
+
357
+ if not findings:
358
+ return finish("failed", "", collector.error or "no output")
359
+
360
+ return finish("ok", findings)
@@ -0,0 +1,51 @@
1
+ """Copilot adapter — a documented stub, and currently a blocked one.
2
+
3
+ **Help wanted, but the obstacle is upstream, not effort.** The scheduler, ledger,
4
+ queue, and digest are all provider-agnostic, so this is one class against the
5
+ contract in ``nightshift.adapters.base`` — perhaps an afternoon. It is not
6
+ written because Copilot CLI has no enforcement primitive that clears our bar.
7
+
8
+ What was checked, as of 2026-07, so the next person need not repeat it:
9
+
10
+ - ``--allow-tool`` / ``--deny-tool`` exist, and deny beats allow. That much is
11
+ the right shape.
12
+ - But denials are per-tool, not per-resource: a denied ``read(x)`` does not stop
13
+ ``shell(cat x)``. A rule one tool honours and another ignores is not a
14
+ boundary, it is a suggestion.
15
+ - https://github.com/github/copilot-cli/issues/2722 (open) reports
16
+ ``--deny-tool="read(...)"`` blocking *all* file reads regardless of pattern,
17
+ and no persistent permission profile for non-interactive use.
18
+ - The docs do not say what happens when the model reaches for a tool that was
19
+ never allowed in programmatic mode. That is the whole guarantee, undocumented.
20
+
21
+ So the blocker is not "nobody wrote it". It is that nightshift cannot promise
22
+ "0 files touched" on top of that, and the promise is the product. Compare
23
+ ``codex.py``, which is implemented precisely because Codex hands us an OS-level
24
+ sandbox to stand on.
25
+
26
+ If upstream ships a real allowlist — one that binds every tool, and is
27
+ documented for non-interactive runs — then this becomes worth writing:
28
+
29
+ 1. ``availability()`` — is the GitHub Copilot CLI on PATH and authenticated?
30
+ 2. ``last_human_use()`` — newest mtime of whatever Copilot writes per session,
31
+ so nightshift stays out of the user's way. Return ``None`` if unknowable.
32
+ 3. ``run()`` — invoke Copilot headlessly against ``project_dir`` **read-only**,
33
+ and map its exit into ``ok`` / ``failed`` / ``timeout``.
34
+
35
+ ``claude_code.py`` and ``codex.py`` are both reference shapes: the first enforces
36
+ read-only with CLI permission flags, the second with a kernel sandbox. Either is
37
+ acceptable. Asking the model nicely is not.
38
+
39
+ https://github.com/kishormorol/nightshift/issues
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from dataclasses import dataclass
45
+
46
+ from nightshift.adapters.base import StubAdapter
47
+
48
+
49
+ @dataclass
50
+ class CopilotAdapter(StubAdapter):
51
+ name: str = "copilot"
nightshift/budget.py ADDED
@@ -0,0 +1,150 @@
1
+ """Per-provider run ledger.
2
+
3
+ Counts *attempts*, not successes: a failed or timed-out run still consumed the
4
+ user's quota, so it still costs budget. The ledger is the only thing standing
5
+ between a cron job and someone's monthly limit, so it errs toward counting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from datetime import date, datetime, timedelta
12
+ from pathlib import Path
13
+
14
+ from nightshift.config import Budget, state_dir
15
+ from nightshift.store import read_json, write_json
16
+
17
+ #: Ledger entries older than this are dropped when the file is loaded.
18
+ RETENTION_DAYS = 30
19
+
20
+
21
+ def day_key(d: date) -> str:
22
+ return d.isoformat()
23
+
24
+
25
+ def week_key(d: date) -> str:
26
+ """ISO week key, e.g. ``2026-W29``. ISO weeks start on Monday."""
27
+ year, week, _ = d.isocalendar()
28
+ return f"{year}-W{week:02d}"
29
+
30
+
31
+ def _week_start(key: str) -> date | None:
32
+ """The Monday of an ISO week key, or ``None`` if unparseable."""
33
+ try:
34
+ year_s, week_s = key.split("-W", 1)
35
+ return date.fromisocalendar(int(year_s), int(week_s), 1)
36
+ except (ValueError, TypeError):
37
+ return None
38
+
39
+
40
+ def _day_start(key: str) -> date | None:
41
+ try:
42
+ return date.fromisoformat(key)
43
+ except (ValueError, TypeError):
44
+ return None
45
+
46
+
47
+ def entry_date(key: str) -> date | None:
48
+ """The date a ledger key refers to, whichever form it takes."""
49
+ return _week_start(key) if "-W" in key else _day_start(key)
50
+
51
+
52
+ @dataclass
53
+ class Usage:
54
+ """What a provider has spent, against what it is allowed."""
55
+
56
+ day: int
57
+ week: int
58
+ max_day: int
59
+ max_week: int
60
+
61
+ @property
62
+ def day_exhausted(self) -> bool:
63
+ return self.day >= self.max_day
64
+
65
+ @property
66
+ def week_exhausted(self) -> bool:
67
+ return self.week >= self.max_week
68
+
69
+ @property
70
+ def exhausted(self) -> bool:
71
+ return self.day_exhausted or self.week_exhausted
72
+
73
+ def reason(self) -> str:
74
+ if self.day_exhausted:
75
+ return f"daily budget spent ({self.day}/{self.max_day} today)"
76
+ if self.week_exhausted:
77
+ return f"weekly budget spent ({self.week}/{self.max_week} this week)"
78
+ return ""
79
+
80
+
81
+ def ledger_path() -> Path:
82
+ return state_dir() / "ledger.json"
83
+
84
+
85
+ class Ledger:
86
+ """A JSON map of ``{provider: {day-or-week key: count}}``."""
87
+
88
+ def __init__(self, path: Path | None = None):
89
+ self.path = path or ledger_path()
90
+ self._data: dict[str, dict[str, int]] = self._load()
91
+
92
+ def _load(self) -> dict[str, dict[str, int]]:
93
+ raw = read_json(self.path, default={})
94
+ if not isinstance(raw, dict):
95
+ return {}
96
+ data: dict[str, dict[str, int]] = {}
97
+ for provider, entries in raw.items():
98
+ if not isinstance(provider, str) or not isinstance(entries, dict):
99
+ continue
100
+ clean: dict[str, int] = {}
101
+ for key, count in entries.items():
102
+ if isinstance(key, str) and isinstance(count, int) and not isinstance(count, bool):
103
+ clean[key] = count
104
+ data[provider] = clean
105
+ return data
106
+
107
+ def prune(self, today: date | None = None) -> int:
108
+ """Drop entries older than :data:`RETENTION_DAYS`. Returns count removed."""
109
+ today = today or date.today()
110
+ cutoff = today - timedelta(days=RETENTION_DAYS)
111
+ removed = 0
112
+ for provider, entries in self._data.items():
113
+ for key in list(entries):
114
+ when = entry_date(key)
115
+ if when is None or when < cutoff:
116
+ del entries[key]
117
+ removed += 1
118
+ return removed
119
+
120
+ def count(self, provider: str, key: str) -> int:
121
+ return self._data.get(provider, {}).get(key, 0)
122
+
123
+ def usage(self, provider: str, budget: Budget, when: datetime | None = None) -> Usage:
124
+ when = when or datetime.now()
125
+ d = when.date()
126
+ return Usage(
127
+ day=self.count(provider, day_key(d)),
128
+ week=self.count(provider, week_key(d)),
129
+ max_day=budget.max_runs_per_day,
130
+ max_week=budget.max_runs_per_week,
131
+ )
132
+
133
+ def increment(self, provider: str, when: datetime | None = None, by: int = 1) -> None:
134
+ """Record ``by`` attempts against ``provider`` and persist immediately.
135
+
136
+ Persisting eagerly matters: if the process dies after spending quota but
137
+ before writing, the next run would happily spend it again.
138
+ """
139
+ when = when or datetime.now()
140
+ d = when.date()
141
+ entries = self._data.setdefault(provider, {})
142
+ for key in (day_key(d), week_key(d)):
143
+ entries[key] = entries.get(key, 0) + by
144
+ self.save()
145
+
146
+ def save(self) -> None:
147
+ write_json(self.path, self._data)
148
+
149
+ def as_dict(self) -> dict[str, dict[str, int]]:
150
+ return {p: dict(e) for p, e in self._data.items()}