alissa-tools-github-revloop 0.31.5__tar.gz → 0.31.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_revloop-0.31.5/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.6}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/alissa.py +37 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/loop.py +178 -3
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/loop_events.py +139 -2
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/prompts.py +207 -6
- alissa_tools_github_revloop-0.31.6/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.31.5/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/trust.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -467,6 +467,26 @@ def pane_path_argv(name: str) -> "list[str]":
|
|
|
467
467
|
]
|
|
468
468
|
|
|
469
469
|
|
|
470
|
+
# What `#{pane_current_command}` reads on a reviewer pane whose `claude` has
|
|
471
|
+
# EXITED (issue #146, devloop #140's set): the shell `alissa tmux new`
|
|
472
|
+
# launched it from, back in the foreground. A live reviewer reads `node`
|
|
473
|
+
# (Claude Code's runtime); a name here means the process the session was
|
|
474
|
+
# spawned for is gone, whatever the session listing says about liveness.
|
|
475
|
+
SHELL_COMMANDS = frozenset((
|
|
476
|
+
"bash", "sh", "zsh", "fish", "dash", "ksh", "ash", "tcsh", "csh",
|
|
477
|
+
))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def pane_command_argv(name: str) -> "list[str]":
|
|
481
|
+
"""`tmux -S <socket> display-message -p -t =<real>: #{pane_current_command}`:
|
|
482
|
+
the foreground process of the session's active pane, by name."""
|
|
483
|
+
check_session_name(name)
|
|
484
|
+
return [
|
|
485
|
+
"tmux", "-S", tmux_socket(), "display-message", "-p",
|
|
486
|
+
"-t", tmux_target(name), "#{pane_current_command}",
|
|
487
|
+
]
|
|
488
|
+
|
|
489
|
+
|
|
470
490
|
def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
|
|
471
491
|
"""`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
|
|
472
492
|
from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
|
|
@@ -1379,6 +1399,23 @@ class Alissa:
|
|
|
1379
1399
|
log.debug("could not read the pane path of %s (%s)", name, exc)
|
|
1380
1400
|
return ""
|
|
1381
1401
|
|
|
1402
|
+
def pane_command(self, name: str) -> str:
|
|
1403
|
+
"""The session's pane current command (`#{pane_current_command}`),
|
|
1404
|
+
or "" when tmux cannot answer. The first-turn death check's one
|
|
1405
|
+
liveness probe (issue #146): a reviewer whose pane reads a shell
|
|
1406
|
+
(`SHELL_COMMANDS`) has lost its `claude`, however alive the tmux
|
|
1407
|
+
session itself is. A READ: runs under dry-run too."""
|
|
1408
|
+
try:
|
|
1409
|
+
argv = pane_command_argv(name)
|
|
1410
|
+
except ValueError as exc:
|
|
1411
|
+
log.warning("pane_command refused: %s", exc)
|
|
1412
|
+
return ""
|
|
1413
|
+
try:
|
|
1414
|
+
return run(argv, timeout=15).strip()
|
|
1415
|
+
except CommandError as exc:
|
|
1416
|
+
log.debug("could not read the pane command of %s (%s)", name, exc)
|
|
1417
|
+
return ""
|
|
1418
|
+
|
|
1382
1419
|
def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
|
|
1383
1420
|
"""Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
|
|
1384
1421
|
-- on ONE managed session's pane. True when tmux accepted them.
|
|
@@ -27,6 +27,7 @@ from typing import Callable
|
|
|
27
27
|
from . import prompts as prompts_mod
|
|
28
28
|
from .alissa import (
|
|
29
29
|
MANAGED_PREFIX,
|
|
30
|
+
SHELL_COMMANDS,
|
|
30
31
|
READINESS_AUTO,
|
|
31
32
|
READINESS_MISSING,
|
|
32
33
|
READINESS_OPERATOR,
|
|
@@ -91,7 +92,13 @@ from .fleet_vitals import (
|
|
|
91
92
|
FleetVitalsPusher,
|
|
92
93
|
build_pusher,
|
|
93
94
|
)
|
|
94
|
-
from .loop_events import
|
|
95
|
+
from .loop_events import (
|
|
96
|
+
LoopEventsEmitter,
|
|
97
|
+
auth_rejected_kind,
|
|
98
|
+
auth_rejected_prefix,
|
|
99
|
+
build_emitter,
|
|
100
|
+
credential_identity,
|
|
101
|
+
)
|
|
95
102
|
from .proc import CommandError
|
|
96
103
|
from .state import State
|
|
97
104
|
|
|
@@ -1330,7 +1337,11 @@ PROMPT_PAGE_COMMENT = (
|
|
|
1330
1337
|
"not answering it, and it is HOLDING new reviewer spawns until the "
|
|
1331
1338
|
"condition clears (live sessions keep running; the rounds they owe are "
|
|
1332
1339
|
"not consumed). Operator options: `login_expired` → re-run the claude "
|
|
1333
|
-
"login on the reviewer container; `
|
|
1340
|
+
"login on the reviewer container; `auth_rejected` → Claude refused the "
|
|
1341
|
+
"reviewer's credential (401): replace the container's token "
|
|
1342
|
+
"(`ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN`) or re-run the claude "
|
|
1343
|
+
"login — the `auth.rejected` loop event names the variable and the "
|
|
1344
|
+
"token's last four characters; `usage_limit` → wait for the reset or "
|
|
1334
1345
|
"raise the plan; `out_of_credits` → top up. The hold lifts on the first "
|
|
1335
1346
|
"poll that no longer sees the notice and in any case after {hold_minutes} "
|
|
1336
1347
|
"min on the same pane: the session is then killed and the stale-round "
|
|
@@ -1357,6 +1368,12 @@ ESCALATION_PROMPT_PAGE = "prompt-page"
|
|
|
1357
1368
|
# slow container.
|
|
1358
1369
|
PROMPT_SETTLE_SECONDS = 3.0
|
|
1359
1370
|
|
|
1371
|
+
# The first-turn death check's "never looked" marker (issue #146, devloop
|
|
1372
|
+
# #140's): the cache maps a session to a finding, to None ("looked: died,
|
|
1373
|
+
# not auth"), or -- absent -- to this, so the two negatives cannot be
|
|
1374
|
+
# confused.
|
|
1375
|
+
_UNCHECKED = object()
|
|
1376
|
+
|
|
1360
1377
|
|
|
1361
1378
|
def prompt_event_kind(kind: str, verb: str, session: str, seq: int) -> str:
|
|
1362
1379
|
"""The ping kind of ONE responder act: `prompt:<kind>:<verb>@<session>#<seq>`.
|
|
@@ -2141,6 +2158,26 @@ class ReviewWatcher:
|
|
|
2141
2158
|
self._prompt_hold: "str | None" = None
|
|
2142
2159
|
# What the responder found and did this pass, for the summary line.
|
|
2143
2160
|
self._prompt_sweep = PromptSweep()
|
|
2161
|
+
# The first-turn death check's memory (issue #146, devloop #140's
|
|
2162
|
+
# `_exit_findings`): every reviewer session whose pane was found back
|
|
2163
|
+
# in a shell inside its round's first stale window, mapped to what
|
|
2164
|
+
# its last lines said -- an `auth_rejected` finding, or None for the
|
|
2165
|
+
# ordinary "died, not auth". The pane is captured ONCE per session;
|
|
2166
|
+
# later passes re-assert a cached finding (so the hold and its
|
|
2167
|
+
# expiry run the login_expired ladder) and never re-read a None.
|
|
2168
|
+
# In-memory on purpose: a restart re-checks once, and the
|
|
2169
|
+
# `auth-rejected:` row is once per session in the ledger, so a
|
|
2170
|
+
# re-observation writes nothing and emits nothing new.
|
|
2171
|
+
self._exit_findings: "dict[str, prompts_mod.PromptFinding | None]" = {}
|
|
2172
|
+
# Where `auth.rejected` reads the credential's NAME and last four
|
|
2173
|
+
# characters (`loop_events.credential_identity`): the process
|
|
2174
|
+
# environment when None; injectable so a test never depends on --
|
|
2175
|
+
# or leaks -- the seat's own token.
|
|
2176
|
+
self.auth_environ: "dict[str, str] | None" = None
|
|
2177
|
+
# Dry-run's once-per-session memory for the `auth-rejected:` row it
|
|
2178
|
+
# would have written (the durable row is production's; the
|
|
2179
|
+
# `_dry_run_drift` split).
|
|
2180
|
+
self._dry_run_auth: "set[str]" = set()
|
|
2144
2181
|
# The settle wait between pressing a dialog's keys and re-capturing
|
|
2145
2182
|
# the pane; injectable so a test never sleeps.
|
|
2146
2183
|
self._sleep: "Callable[[float], None]" = time.sleep
|
|
@@ -5742,6 +5779,18 @@ class ReviewWatcher:
|
|
|
5742
5779
|
answered = 0
|
|
5743
5780
|
for ses in sessions:
|
|
5744
5781
|
live.add(ses.name)
|
|
5782
|
+
# The first-turn death check (issue #146) runs BEFORE the two
|
|
5783
|
+
# triggers below: a pane whose `claude` exited is a shell at
|
|
5784
|
+
# rest, which neither a marker nor the quiet gate reads as a
|
|
5785
|
+
# prompt, and which the banner classifier would call idle.
|
|
5786
|
+
exited = self._first_turn_death(ses)
|
|
5787
|
+
if exited is not None:
|
|
5788
|
+
outcome = self._answer_prompt(ses, None, exited, mode)
|
|
5789
|
+
waiting.append(outcome["waiting"])
|
|
5790
|
+
answered += outcome["answered"]
|
|
5791
|
+
if outcome["hold"] and hold is None:
|
|
5792
|
+
hold = outcome["hold"]
|
|
5793
|
+
continue
|
|
5745
5794
|
marker = self._waiting_marker(ses.tmux_name)
|
|
5746
5795
|
quiet = ses.quiet_for
|
|
5747
5796
|
if marker is None and (
|
|
@@ -5772,8 +5821,122 @@ class ReviewWatcher:
|
|
|
5772
5821
|
hold = outcome["hold"]
|
|
5773
5822
|
self._sighting_prune(live)
|
|
5774
5823
|
self._prune_prompt_pings(live)
|
|
5824
|
+
for gone in [n for n in self._exit_findings if n not in live]:
|
|
5825
|
+
del self._exit_findings[gone]
|
|
5775
5826
|
return PromptSweep(waiting=tuple(waiting), hold=hold, answered=answered)
|
|
5776
5827
|
|
|
5828
|
+
def _first_turn_death(
|
|
5829
|
+
self, ses: ManagedSession
|
|
5830
|
+
) -> "prompts_mod.PromptFinding | None":
|
|
5831
|
+
"""The first-turn death check (issue #146; devloop #140's, on the
|
|
5832
|
+
reviewer seat): an `auth_rejected` finding with `source: "exit"` for
|
|
5833
|
+
a reviewer session whose `claude` died on a 401 before it could do
|
|
5834
|
+
anything, else None.
|
|
5835
|
+
|
|
5836
|
+
The candidate is narrow by design, and every leg is cheap or rare:
|
|
5837
|
+
a session THIS ledger spawned (a spawn row names it -- the skill's
|
|
5838
|
+
hand-driven `review-pr-<n>` rounds have none and no spawn time to
|
|
5839
|
+
measure a window from) inside its round's first stale window
|
|
5840
|
+
(STALE_ROUND_SECONDS), whose pane's current command is back in
|
|
5841
|
+
`SHELL_COMMANDS` (one `display-message` per young session per pass).
|
|
5842
|
+
Its last lines are captured ONCE and read by
|
|
5843
|
+
`prompts.classify_exit`; a match is remembered and re-asserted on
|
|
5844
|
+
later passes while the pane is still a shell, so the page-and-hold
|
|
5845
|
+
path and its one-hour expiry run exactly as they do for a
|
|
5846
|
+
login_expired banner -- and the expiry's kill ages the round's spawn
|
|
5847
|
+
row, so the stale-round edge re-enters THE SAME ROUND (no verdict
|
|
5848
|
+
was submitted, so `completed + 1` has not moved): an auth death
|
|
5849
|
+
never burns a round number. No match is the ordinary "died, not
|
|
5850
|
+
auth": remembered too (never re-read), and nothing new is emitted --
|
|
5851
|
+
the stale-round respawn is the remedy it always was. An unreadable
|
|
5852
|
+
pane (tmux answered nothing) is not remembered: the check runs again
|
|
5853
|
+
next pass rather than filing a blank capture as "not auth"."""
|
|
5854
|
+
name = ses.name
|
|
5855
|
+
cached = self._exit_findings.get(name, _UNCHECKED)
|
|
5856
|
+
if cached is None:
|
|
5857
|
+
return None
|
|
5858
|
+
if cached is not _UNCHECKED:
|
|
5859
|
+
command = self.alissa.pane_command(name)
|
|
5860
|
+
if command in SHELL_COMMANDS or not command:
|
|
5861
|
+
# Still the dead shell -- or tmux could not answer ("" is a
|
|
5862
|
+
# failed read, never a command), in which case the last thing
|
|
5863
|
+
# known stands: forgetting it here would re-arm a second
|
|
5864
|
+
# capture inside the stale window and drop the hold and its
|
|
5865
|
+
# expiry past it, for a shell still dead (devloop PR #141
|
|
5866
|
+
# review round 1).
|
|
5867
|
+
return cached # type: ignore[return-value]
|
|
5868
|
+
# A named, non-shell command: someone re-ran claude by hand in
|
|
5869
|
+
# the session -- a live pane again, and the ordinary triggers own
|
|
5870
|
+
# it from here.
|
|
5871
|
+
del self._exit_findings[name]
|
|
5872
|
+
return None
|
|
5873
|
+
row = self.state.find_spawn_by_session(name)
|
|
5874
|
+
if row is None:
|
|
5875
|
+
return None
|
|
5876
|
+
age = time.time() - int(row["spawned_at"])
|
|
5877
|
+
if age >= STALE_ROUND_SECONDS:
|
|
5878
|
+
return None
|
|
5879
|
+
if self.alissa.pane_command(name) not in SHELL_COMMANDS:
|
|
5880
|
+
return None
|
|
5881
|
+
pane = self.alissa.capture_pane(name, prompts_mod.PANE_LINES)
|
|
5882
|
+
if not pane.strip():
|
|
5883
|
+
log.debug(
|
|
5884
|
+
"first-turn death check: %s is a shell but its pane could not "
|
|
5885
|
+
"be read — re-checked next pass", name,
|
|
5886
|
+
)
|
|
5887
|
+
return None
|
|
5888
|
+
finding = prompts_mod.classify_exit(pane)
|
|
5889
|
+
self._exit_findings[name] = finding
|
|
5890
|
+
if finding is None:
|
|
5891
|
+
log.info(
|
|
5892
|
+
"first-turn death check: %s exited to a shell %d s after its "
|
|
5893
|
+
"spawn with no auth wording in its last lines — died, not "
|
|
5894
|
+
"auth; the stale-round edge owns it", name, int(age),
|
|
5895
|
+
)
|
|
5896
|
+
return finding
|
|
5897
|
+
|
|
5898
|
+
def _record_auth_rejected(
|
|
5899
|
+
self,
|
|
5900
|
+
name: str,
|
|
5901
|
+
anchor: "PromptAnchor | None",
|
|
5902
|
+
finding: "prompts_mod.PromptFinding",
|
|
5903
|
+
) -> None:
|
|
5904
|
+
"""The `auth.rejected` row (issue #146): ONE `auth-rejected:` ping
|
|
5905
|
+
per session, which the loop-events emitter derives the event from.
|
|
5906
|
+
The row carries the vocabulary tokens, the round, the credential's
|
|
5907
|
+
variable NAME and its last four characters -- read here, at
|
|
5908
|
+
observation, so a later backfill still names the token that was
|
|
5909
|
+
refused -- and never the signature or any other pane text. An
|
|
5910
|
+
anchor-less session (no spawn row, a name that resolves to no single
|
|
5911
|
+
watched repo) is recorded under ('', 0): the event still goes, with
|
|
5912
|
+
no PR. Dry-run writes nothing and logs the row once per session."""
|
|
5913
|
+
if self.state.ping_seen_since(auth_rejected_prefix(name), 0):
|
|
5914
|
+
return
|
|
5915
|
+
env_name, suffix = credential_identity(self.auth_environ)
|
|
5916
|
+
if self.config.dry_run:
|
|
5917
|
+
if name not in self._dry_run_auth:
|
|
5918
|
+
self._dry_run_auth.add(name)
|
|
5919
|
+
log.info(
|
|
5920
|
+
"[dry-run] would record auth.rejected for %s (source %s, "
|
|
5921
|
+
"matched %s, envName %s)",
|
|
5922
|
+
name, finding.source, finding.matched, env_name,
|
|
5923
|
+
)
|
|
5924
|
+
return
|
|
5925
|
+
self.state.record_ping(
|
|
5926
|
+
anchor.repo if anchor is not None else "",
|
|
5927
|
+
anchor.number if anchor is not None else 0,
|
|
5928
|
+
auth_rejected_kind(
|
|
5929
|
+
name, anchor.round if anchor is not None else None,
|
|
5930
|
+
finding.source, finding.matched, env_name, suffix,
|
|
5931
|
+
),
|
|
5932
|
+
)
|
|
5933
|
+
log.warning(
|
|
5934
|
+
"auth.rejected: claude refused the credential of reviewer session "
|
|
5935
|
+
"%s (source %s, matched %s, envName %s) — the loop event names the "
|
|
5936
|
+
"token's last four characters; spawns are held",
|
|
5937
|
+
name, finding.source, finding.matched, env_name,
|
|
5938
|
+
)
|
|
5939
|
+
|
|
5777
5940
|
def _answer_prompt(
|
|
5778
5941
|
self,
|
|
5779
5942
|
ses: ManagedSession,
|
|
@@ -5899,10 +6062,16 @@ class ReviewWatcher:
|
|
|
5899
6062
|
tail = " · killed; the stale-round edge re-enters the same round next poll"
|
|
5900
6063
|
elif action.sends_keys and not sent and not self.config.dry_run:
|
|
5901
6064
|
tail = " · keys not sent (tmux refused)"
|
|
6065
|
+
# The auth kinds name the wording and where it was read (the
|
|
6066
|
+
# `auth.rejected` event's own tokens, never the pane's text).
|
|
6067
|
+
auth_tail = (
|
|
6068
|
+
f" · matched {finding.matched} · source {finding.source}"
|
|
6069
|
+
if finding.kind in prompts_mod.AUTH_KINDS and finding.matched else ""
|
|
6070
|
+
)
|
|
5902
6071
|
line = self._activity_line(
|
|
5903
6072
|
name, anchor.round,
|
|
5904
6073
|
f"{prefix}: {finding.kind} → {action.verb} ({action.reason})"
|
|
5905
|
-
f"{f' · target {target_rel}' if target_rel else ''}"
|
|
6074
|
+
f"{f' · target {target_rel}' if target_rel else ''}{auth_tail}"
|
|
5906
6075
|
f" · {since} s after it appeared{tail}",
|
|
5907
6076
|
anchor.cap,
|
|
5908
6077
|
)
|
|
@@ -5920,6 +6089,12 @@ class ReviewWatcher:
|
|
|
5920
6089
|
"spawn row, and the name resolves to no single watched repo) — "
|
|
5921
6090
|
"%s → %s recorded in the log only", name, finding.kind, action.verb,
|
|
5922
6091
|
)
|
|
6092
|
+
# The `auth.rejected` record (issue #146): the two AUTH_KINDS are the
|
|
6093
|
+
# same signal. Live mode only: `observe` pages nobody and holds
|
|
6094
|
+
# nothing, and Studio acts on this event (it flips the stored
|
|
6095
|
+
# credential), so an observing daemon must not make that claim.
|
|
6096
|
+
if finding.kind in prompts_mod.AUTH_KINDS and live_mode:
|
|
6097
|
+
self._record_auth_rejected(name, anchor, finding)
|
|
5923
6098
|
return {
|
|
5924
6099
|
"waiting": {
|
|
5925
6100
|
"session": name, "kind": finding.kind,
|
|
@@ -46,13 +46,36 @@ Design rules, all load-bearing:
|
|
|
46
46
|
between passes) is simply not observed; a row enriched from another table
|
|
47
47
|
(`stability_notices` onto a `stability:` ping) reads whatever that table
|
|
48
48
|
says NOW, which is also what the guard itself decides from.
|
|
49
|
+
|
|
50
|
+
One event is not a table read but a ROW the watcher writes for it, and it is
|
|
51
|
+
still ledger-derived: ``auth.rejected`` (issue #146; devloop #140's event,
|
|
52
|
+
Studio design `managed-dark-factory-claude-auth.md` §2.6, lane L5) — a
|
|
53
|
+
reviewer session REFUSED by Claude. The prompt responder saw a documented
|
|
54
|
+
401 wording (or the login-expired banner) on a live pane, or the first-turn
|
|
55
|
+
death check found one in the last lines of a pane whose `claude` had already
|
|
56
|
+
exited. The watcher records ONE `auth-rejected:` ping per session
|
|
57
|
+
(`auth_rejected_kind`), and the event is derived from it, keyed on the
|
|
58
|
+
session name, so one session emits at most one whatever the pass count or a
|
|
59
|
+
restart's backfill. ``data: { status: 401, source: "pane" | "exit", matched,
|
|
60
|
+
envName, tokenSuffix }`` — devloop's shape exactly. `envName` is which of
|
|
61
|
+
`ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` the container carries, read
|
|
62
|
+
BY NAME ONLY (`null` on a persisted `claude /login`); `tokenSuffix` is the
|
|
63
|
+
last four characters of that variable's value (`null` beside a `null`
|
|
64
|
+
envName), computed in-process by `credential_identity` AT OBSERVATION and
|
|
65
|
+
folded into the row -- never re-read at derivation, so a backfill after the
|
|
66
|
+
operator replaced the token still carries the suffix of the token that was
|
|
67
|
+
refused. The suffix is the design's R1 key: Studio's reducer flips a
|
|
68
|
+
credential only when the suffix matches the stored row, so a container still
|
|
69
|
+
running an OLD token after a same-mode replace cannot flip the new, good
|
|
70
|
+
credential. `reason` is a fixed sentence, never the pane's text.
|
|
49
71
|
"""
|
|
50
72
|
|
|
51
73
|
from __future__ import annotations
|
|
52
74
|
|
|
53
75
|
import logging
|
|
76
|
+
import os
|
|
54
77
|
import re
|
|
55
|
-
from typing import Any
|
|
78
|
+
from typing import Any, Mapping
|
|
56
79
|
|
|
57
80
|
from .alissa_client import (
|
|
58
81
|
AlissaAuthError,
|
|
@@ -90,6 +113,89 @@ _PROMPT_PAGE_RE = re.compile(
|
|
|
90
113
|
)
|
|
91
114
|
|
|
92
115
|
|
|
116
|
+
# The `auth.rejected` row (issue #146): one per SESSION,
|
|
117
|
+
# `auth-rejected:<session>#<round>:<source>:<matched>:<envName>:<suffix>`.
|
|
118
|
+
# The session leads so "already recorded for this session" is one prefix read
|
|
119
|
+
# (`auth_rejected_prefix`); the suffix trails because it is the one field
|
|
120
|
+
# whose characters this module does not choose. `round`, `envName` and the
|
|
121
|
+
# suffix are empty when unknown / unset.
|
|
122
|
+
AUTH_REJECTED_PREFIX = "auth-rejected:"
|
|
123
|
+
_AUTH_REJECTED_RE = re.compile(
|
|
124
|
+
r"^auth-rejected:(?P<session>[^#:@]+)#(?P<round>\d*):(?P<source>pane|exit):"
|
|
125
|
+
r"(?P<matched>[a-z0-9_]+):(?P<env>[A-Z_]*):(?P<suffix>.{0,4})$"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# The credential variables a container may carry, in Claude Code's own
|
|
129
|
+
# precedence order (design §1.4: `ANTHROPIC_API_KEY` outranks
|
|
130
|
+
# `CLAUDE_CODE_OAUTH_TOKEN`; both outrank the persisted login). Read by NAME
|
|
131
|
+
# only -- the value is touched for its last four characters and for nothing
|
|
132
|
+
# else, and never leaves `credential_identity` whole. devloop's constants.
|
|
133
|
+
AUTH_ENV_NAMES = ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN")
|
|
134
|
+
TOKEN_SUFFIX_CHARS = 4
|
|
135
|
+
AUTH_STATUS = 401
|
|
136
|
+
AUTH_SOURCES = ("pane", "exit")
|
|
137
|
+
AUTH_MATCHED_DEFAULT = "api_error_401"
|
|
138
|
+
|
|
139
|
+
# The fixed `reason` sentences -- never the pane's text (the contract's
|
|
140
|
+
# `reason` is ≤ 2000 characters, and a pane is another agent's screen).
|
|
141
|
+
AUTH_REASONS = {
|
|
142
|
+
"pane": "claude answered 401 — the reviewer's pane shows an auth banner",
|
|
143
|
+
"exit": "claude answered 401 on the first turn and exited",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def credential_identity(
|
|
148
|
+
environ: "Mapping[str, str] | None" = None,
|
|
149
|
+
) -> "tuple[str | None, str | None]":
|
|
150
|
+
"""`(envName, tokenSuffix)` for the container this daemon runs in: the
|
|
151
|
+
first of AUTH_ENV_NAMES that is set and non-blank, and the last
|
|
152
|
+
TOKEN_SUFFIX_CHARS characters of its value; `(None, None)` when neither
|
|
153
|
+
is set -- a container running on a persisted `claude /login`. The
|
|
154
|
+
reviewers are spawned on this same container by `alissa tmux`, so the
|
|
155
|
+
daemon's environment IS theirs. devloop's function, verbatim in
|
|
156
|
+
behaviour: a blank value reads as unset, a value shorter than four
|
|
157
|
+
characters yields what there is."""
|
|
158
|
+
env = os.environ if environ is None else environ
|
|
159
|
+
for name in AUTH_ENV_NAMES:
|
|
160
|
+
value = str(env.get(name) or "").strip()
|
|
161
|
+
if value:
|
|
162
|
+
return name, value[-TOKEN_SUFFIX_CHARS:]
|
|
163
|
+
return None, None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def auth_rejected_prefix(session: str) -> str:
|
|
167
|
+
"""The prefix every `auth-rejected:` row of ONE session starts with --
|
|
168
|
+
the watcher's "already recorded" read (`State.ping_seen_since`)."""
|
|
169
|
+
return f"{AUTH_REJECTED_PREFIX}{session}#"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def auth_rejected_kind(
|
|
173
|
+
session: str,
|
|
174
|
+
round_: "int | None",
|
|
175
|
+
source: str,
|
|
176
|
+
matched: "str | None",
|
|
177
|
+
env_name: "str | None",
|
|
178
|
+
suffix: "str | None",
|
|
179
|
+
) -> str:
|
|
180
|
+
"""The ledger kind of ONE refusal (see AUTH_REJECTED_PREFIX). `source`
|
|
181
|
+
outside AUTH_SOURCES reads as `pane`, a missing `matched` as the bare
|
|
182
|
+
401 -- the event's vocabulary is closed, and the row is its source."""
|
|
183
|
+
return (
|
|
184
|
+
f"{auth_rejected_prefix(session)}{'' if round_ is None else int(round_)}:"
|
|
185
|
+
f"{source if source in AUTH_SOURCES else 'pane'}:"
|
|
186
|
+
f"{matched or AUTH_MATCHED_DEFAULT}:{env_name or ''}:"
|
|
187
|
+
f"{(suffix or '')[-TOKEN_SUFFIX_CHARS:]}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def auth_rejected_key(session: str) -> str:
|
|
192
|
+
"""One `auth.rejected` per SESSION (issue #146, devloop's key shape):
|
|
193
|
+
reviewer names are nonce-unique per spawn and a session is refused once
|
|
194
|
+
-- the pane detector seeing the same banner on every pass, or the exit
|
|
195
|
+
check re-asserting a dead shell, is the same refusal."""
|
|
196
|
+
return f"{SEAT}:auth.rejected:{session}"
|
|
197
|
+
|
|
198
|
+
|
|
93
199
|
def _ms(seconds: "int | float") -> int:
|
|
94
200
|
"""A ledger stamp (epoch seconds) as the API's epoch-ms `at`."""
|
|
95
201
|
return int(seconds) * 1000
|
|
@@ -253,7 +359,8 @@ def _capped_events(
|
|
|
253
359
|
def _ping_events(
|
|
254
360
|
rows: "list[dict]", notices: "dict[tuple[str, int], dict]"
|
|
255
361
|
) -> "list[tuple[int, dict]]":
|
|
256
|
-
"""`stalled`, `stability.hold
|
|
362
|
+
"""`stalled`, `stability.hold`, `checks.held`, the prompt responder's
|
|
363
|
+
two families and `auth.rejected` out of the ping ledger.
|
|
257
364
|
|
|
258
365
|
`kind` is free-form text carrying the episode identity, so it is parsed
|
|
259
366
|
by prefix; kinds this module does not report (`activity-deferred:`,
|
|
@@ -336,6 +443,36 @@ def _ping_events(
|
|
|
336
443
|
"session": match.group("session"), "ledgerKind": kind,
|
|
337
444
|
},
|
|
338
445
|
))
|
|
446
|
+
elif kind.startswith(AUTH_REJECTED_PREFIX):
|
|
447
|
+
match = _AUTH_REJECTED_RE.match(kind)
|
|
448
|
+
if match is None:
|
|
449
|
+
continue
|
|
450
|
+
session = match.group("session")
|
|
451
|
+
source = match.group("source")
|
|
452
|
+
round_str = match.group("round")
|
|
453
|
+
out.append(_event(
|
|
454
|
+
"auth.rejected",
|
|
455
|
+
at,
|
|
456
|
+
auth_rejected_key(session),
|
|
457
|
+
repo=repo or None,
|
|
458
|
+
# An anchor-less refusal (the session resolved to no watched
|
|
459
|
+
# PR) is recorded under ('', 0): the event still goes, with
|
|
460
|
+
# no repo and no PR rather than a made-up one.
|
|
461
|
+
pr=number if number > 0 else None,
|
|
462
|
+
round_=int(round_str) if round_str else None,
|
|
463
|
+
session=session,
|
|
464
|
+
reason=AUTH_REASONS[source],
|
|
465
|
+
data={
|
|
466
|
+
"status": AUTH_STATUS,
|
|
467
|
+
"source": source,
|
|
468
|
+
"matched": match.group("matched"),
|
|
469
|
+
"envName": match.group("env") or None,
|
|
470
|
+
"tokenSuffix": (
|
|
471
|
+
match.group("suffix") or None
|
|
472
|
+
if match.group("env") else None
|
|
473
|
+
),
|
|
474
|
+
},
|
|
475
|
+
))
|
|
339
476
|
elif kind.startswith(PROMPT_PREFIX):
|
|
340
477
|
match = _PROMPT_RE.match(kind)
|
|
341
478
|
if match is None:
|
|
@@ -66,6 +66,12 @@ KIND_PERMISSION = "permission"
|
|
|
66
66
|
KIND_TRUST = "trust"
|
|
67
67
|
KIND_RESUME_PICKER = "resume_picker"
|
|
68
68
|
KIND_LOGIN_EXPIRED = "login_expired"
|
|
69
|
+
# A 401 from the API on a reviewer's turn (issue #146, devloop #140, Studio design
|
|
70
|
+
# managed-dark-factory-claude-auth §2.6): the credential the container
|
|
71
|
+
# carries -- an env token or the persisted login -- was REFUSED. Same
|
|
72
|
+
# family as login_expired (an account-level notice: page, hold spawns, no
|
|
73
|
+
# keys) and the same loop-event signal (`auth.rejected`, see `matched`).
|
|
74
|
+
KIND_AUTH_REJECTED = "auth_rejected"
|
|
69
75
|
KIND_USAGE_LIMIT = "usage_limit"
|
|
70
76
|
KIND_OUT_OF_CREDITS = "out_of_credits"
|
|
71
77
|
KIND_UNKNOWN_DIALOG = "unknown_dialog"
|
|
@@ -76,6 +82,7 @@ KINDS = (
|
|
|
76
82
|
KIND_TRUST,
|
|
77
83
|
KIND_RESUME_PICKER,
|
|
78
84
|
KIND_LOGIN_EXPIRED,
|
|
85
|
+
KIND_AUTH_REJECTED,
|
|
79
86
|
KIND_USAGE_LIMIT,
|
|
80
87
|
KIND_OUT_OF_CREDITS,
|
|
81
88
|
KIND_UNKNOWN_DIALOG,
|
|
@@ -84,7 +91,38 @@ KINDS = (
|
|
|
84
91
|
# The account-level kinds: nothing a keystroke can fix. The policy pages the
|
|
85
92
|
# operator and holds NEW spawns while the condition stands, so attempts are
|
|
86
93
|
# not burned against a dead account.
|
|
87
|
-
ACCOUNT_KINDS = frozenset((
|
|
94
|
+
ACCOUNT_KINDS = frozenset((
|
|
95
|
+
KIND_LOGIN_EXPIRED, KIND_AUTH_REJECTED, KIND_USAGE_LIMIT, KIND_OUT_OF_CREDITS,
|
|
96
|
+
))
|
|
97
|
+
|
|
98
|
+
# The two kinds that ARE the `auth.rejected` loop event (issue #146, devloop #140): a
|
|
99
|
+
# refused credential, whichever wording Claude Code chose for it. The
|
|
100
|
+
# existing login_expired banner is the same signal under its old name.
|
|
101
|
+
AUTH_KINDS = frozenset((KIND_LOGIN_EXPIRED, KIND_AUTH_REJECTED))
|
|
102
|
+
|
|
103
|
+
# -- the auth.rejected vocabulary (design §2.6) ---------------------------
|
|
104
|
+
|
|
105
|
+
# `data.matched` -- WHICH documented wording the pane showed. A closed list,
|
|
106
|
+
# settled in the design before any daemon emits it; Studio's reducer reads
|
|
107
|
+
# the suffix, not this, so the token is for the operator's eyes.
|
|
108
|
+
MATCHED_API_ERROR_401 = "api_error_401"
|
|
109
|
+
MATCHED_INVALID_API_KEY = "invalid_api_key"
|
|
110
|
+
MATCHED_OAUTH_EXPIRED = "oauth_expired"
|
|
111
|
+
MATCHED_OAUTH_REVOKED = "oauth_revoked"
|
|
112
|
+
MATCHED_LOGIN_EXPIRED = "login_expired"
|
|
113
|
+
AUTH_MATCHED = (
|
|
114
|
+
MATCHED_API_ERROR_401,
|
|
115
|
+
MATCHED_INVALID_API_KEY,
|
|
116
|
+
MATCHED_OAUTH_EXPIRED,
|
|
117
|
+
MATCHED_OAUTH_REVOKED,
|
|
118
|
+
MATCHED_LOGIN_EXPIRED,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# `data.source` -- where the detector read the wording: a still-open pane
|
|
122
|
+
# (the banner classifier) or the last lines of a pane whose `claude`
|
|
123
|
+
# already exited back to the shell (the first-turn death check).
|
|
124
|
+
SOURCE_PANE = "pane"
|
|
125
|
+
SOURCE_EXIT = "exit"
|
|
88
126
|
|
|
89
127
|
# -- keys ----------------------------------------------------------------
|
|
90
128
|
|
|
@@ -138,6 +176,16 @@ BANNER_LINES = 3
|
|
|
138
176
|
# excerpt the issue allows: three lines, secrets-scrubbed, never more).
|
|
139
177
|
EXCERPT_LINES = 3
|
|
140
178
|
|
|
179
|
+
# How many non-blank lines from the bottom the first-turn death check reads
|
|
180
|
+
# (issue #146, devloop #140). A `claude` that 401s prints the error and exits; the shell
|
|
181
|
+
# then draws its prompt (and, on some shells, a blank line or two) under it,
|
|
182
|
+
# so the wording sits a few lines above the bottom -- never as the LAST
|
|
183
|
+
# thing on screen, which is why the banner rule cannot see it. Twelve lines
|
|
184
|
+
# hold the error, the JSON body Claude Code sometimes wraps it in, and the
|
|
185
|
+
# shell chrome, and nothing older: the scrollback above is the reviewer's own
|
|
186
|
+
# turn, where a quoted phrase would be a false positive.
|
|
187
|
+
EXIT_SCAN_LINES = 12
|
|
188
|
+
|
|
141
189
|
# The default per-kind page cadence for the account-level kinds: one
|
|
142
190
|
# operator page per kind per six hours through the escalation ledger.
|
|
143
191
|
PAGE_WINDOW_SECONDS = 6 * 3600
|
|
@@ -177,6 +225,34 @@ _LOGIN_EXPIRED_RE = re.compile(
|
|
|
177
225
|
r"(?:Your )?(?:session|login|token) (?:has )?expired\b)",
|
|
178
226
|
re.IGNORECASE,
|
|
179
227
|
)
|
|
228
|
+
# The 401 wordings Anthropic documents (issue #146, devloop #140; design §1.4: the Claude
|
|
229
|
+
# Code *Errors* page and the Platform *Authentication* page): `API Error:
|
|
230
|
+
# 401 …`, `Invalid authentication credentials`, `Invalid API key`, `OAuth
|
|
231
|
+
# token has expired`, `OAuth token revoked`. Anchored like the banners
|
|
232
|
+
# above (an optional error glyph is the only thing allowed before the
|
|
233
|
+
# wording), and read on the de-decorated line: Claude Code draws the API
|
|
234
|
+
# error on a `⎿` row of its own under the turn that failed, so
|
|
235
|
+
# `_banner_line` admits THAT row for this one family -- a `●` turn or tool
|
|
236
|
+
# call above it in the same paragraph still makes it a reviewer's output.
|
|
237
|
+
_AUTH_REJECTED_RE = re.compile(
|
|
238
|
+
r"^(?:[✗✘⚠]\s*)?(?:API Error:\s*401\b|Invalid authentication credentials\b|"
|
|
239
|
+
r"Invalid API key\b|OAuth token (?:has )?expired\b|"
|
|
240
|
+
r"OAuth token (?:has been |was )?revoked\b)",
|
|
241
|
+
re.IGNORECASE,
|
|
242
|
+
)
|
|
243
|
+
# The specific wordings, tested on an auth line AFTER `_AUTH_REJECTED_RE`
|
|
244
|
+
# admitted it, most specific first; a line none of them names is the bare
|
|
245
|
+
# 401 (`api_error_401`), which is also what `API Error: 401 Invalid
|
|
246
|
+
# authentication credentials` reads as -- the credentials sentence is the
|
|
247
|
+
# 401's own message.
|
|
248
|
+
_AUTH_MATCHED_RES = (
|
|
249
|
+
(MATCHED_INVALID_API_KEY, re.compile(r"Invalid API key\b", re.IGNORECASE)),
|
|
250
|
+
(MATCHED_OAUTH_EXPIRED, re.compile(r"OAuth token (?:has )?expired\b", re.IGNORECASE)),
|
|
251
|
+
(MATCHED_OAUTH_REVOKED, re.compile(r"OAuth token (?:has been |was )?revoked\b", re.IGNORECASE)),
|
|
252
|
+
)
|
|
253
|
+
# A `⎿` tool-OUTPUT row specifically (the `●` turn row is the other half of
|
|
254
|
+
# `_OUTPUT_ROW_RE`): the row Claude Code draws its own API error on.
|
|
255
|
+
_TOOL_OUTPUT_ROW_RE = re.compile(r"^\s*⎿")
|
|
180
256
|
_USAGE_LIMIT_RE = re.compile(
|
|
181
257
|
r"^(?:(?:You've|You have) hit your (?:(?:weekly|daily|5-hour|session|usage) )?limit\b|"
|
|
182
258
|
r"(?:Weekly|Daily|Session|Usage) limit reached\b|Usage limit\b|"
|
|
@@ -194,6 +270,11 @@ _OUT_OF_CREDITS_RE = re.compile(
|
|
|
194
270
|
# (`⎿ ...`): a worker's output, which Claude Code never uses to draw a
|
|
195
271
|
# notice of its own. Tested on the ANSI-stripped line, decoration intact.
|
|
196
272
|
_OUTPUT_ROW_RE = re.compile(r"^\s*[●⎿]")
|
|
273
|
+
# The row Claude Code draws for a SUBMITTED prompt (`> implement issue
|
|
274
|
+
# #7`) or the bare input prompt at column 0 (`❯`): the boundary between
|
|
275
|
+
# one reviewer turn and the next, which is what releases the lines below
|
|
276
|
+
# it from the `●` above (issue #146; devloop #140, its PR #141 review round 2).
|
|
277
|
+
_USER_PROMPT_ROW_RE = re.compile(r"^[>❯](?:\s|$)")
|
|
197
278
|
# The dialog's path line, as the trust gate draws it: the path and nothing
|
|
198
279
|
# else on the line.
|
|
199
280
|
_PATH_LINE_RE = re.compile(r"^/[^\s'\"]+$")
|
|
@@ -289,14 +370,31 @@ def _is_banner_chrome(text: str) -> bool:
|
|
|
289
370
|
return text in ("❯", ">") or _is_chrome(text)
|
|
290
371
|
|
|
291
372
|
|
|
373
|
+
def auth_matched(text: str) -> "str | None":
|
|
374
|
+
"""Which documented 401 wording this (plain) line is, as the
|
|
375
|
+
`data.matched` token, or None when it is not an auth-rejected line at
|
|
376
|
+
all. The login-expired banner is NOT read here (its own regex and kind
|
|
377
|
+
already exist); `classify` stamps that finding `login_expired`."""
|
|
378
|
+
if not _AUTH_REJECTED_RE.search(text):
|
|
379
|
+
return None
|
|
380
|
+
for token, pattern in _AUTH_MATCHED_RES:
|
|
381
|
+
if pattern.search(text):
|
|
382
|
+
return token
|
|
383
|
+
return MATCHED_API_ERROR_401
|
|
384
|
+
|
|
385
|
+
|
|
292
386
|
def _banner_line(lines: "list[str]") -> "tuple[int, str] | None":
|
|
293
387
|
"""The one line an account-level banner could be, as (index into
|
|
294
388
|
`lines`, plain text), or None. Bottom-up: chrome and the bare input
|
|
295
389
|
prompt are skipped, then the contiguous paragraph above them is read
|
|
296
390
|
up to the blank line or chrome that bounds it. A `●`/`⎿` tool row
|
|
297
391
|
anywhere in that paragraph makes it a worker's output (the row and its
|
|
298
|
-
indented continuation lines), never a banner
|
|
299
|
-
|
|
392
|
+
indented continuation lines), never a banner -- with ONE exception: a
|
|
393
|
+
`⎿` row that IS a documented 401 wording is how Claude Code draws its
|
|
394
|
+
own API error (issue #146, devloop #140), so that row is admitted as a candidate,
|
|
395
|
+
and a `●` turn above it in the same paragraph still voids the whole
|
|
396
|
+
paragraph. Otherwise the paragraph's last BANNER_LINES lines are the
|
|
397
|
+
candidates."""
|
|
300
398
|
index = len(lines) - 1
|
|
301
399
|
while index >= 0 and _is_banner_chrome(_plain(lines[index])):
|
|
302
400
|
index -= 1
|
|
@@ -306,12 +404,16 @@ def _banner_line(lines: "list[str]") -> "tuple[int, str] | None":
|
|
|
306
404
|
text = _plain(raw)
|
|
307
405
|
if _is_banner_chrome(text):
|
|
308
406
|
break
|
|
309
|
-
if _OUTPUT_ROW_RE.match(raw)
|
|
407
|
+
if _OUTPUT_ROW_RE.match(raw) and not (
|
|
408
|
+
_TOOL_OUTPUT_ROW_RE.match(raw) and _AUTH_REJECTED_RE.search(text)
|
|
409
|
+
):
|
|
310
410
|
return None
|
|
311
411
|
paragraph.append((index, text))
|
|
312
412
|
index -= 1
|
|
313
413
|
for index, text in paragraph[:BANNER_LINES]:
|
|
314
|
-
for pattern in (
|
|
414
|
+
for pattern in (
|
|
415
|
+
_LOGIN_EXPIRED_RE, _AUTH_REJECTED_RE, _USAGE_LIMIT_RE, _OUT_OF_CREDITS_RE,
|
|
416
|
+
):
|
|
315
417
|
if pattern.search(text):
|
|
316
418
|
return index, text
|
|
317
419
|
return None
|
|
@@ -339,7 +441,12 @@ class PromptFinding:
|
|
|
339
441
|
it, scrubbed, for the DEBUG log and nothing else. `pane_hash` fingerprints
|
|
340
442
|
the dialog so the loop can tell "the same pane, one poll later" from "a
|
|
341
443
|
new prompt": the unknown-dialog ladder and the answered/unanswered
|
|
342
|
-
re-capture both hang off it.
|
|
444
|
+
re-capture both hang off it.
|
|
445
|
+
|
|
446
|
+
`matched` and `source` are the `auth.rejected` event's vocabulary
|
|
447
|
+
(issue #146, devloop #140), set on the two AUTH_KINDS only: which documented wording
|
|
448
|
+
was seen (`AUTH_MATCHED`) and whether it was read off a live pane or
|
|
449
|
+
the last lines of an exited one (`SOURCE_PANE` / `SOURCE_EXIT`)."""
|
|
343
450
|
|
|
344
451
|
kind: str
|
|
345
452
|
signature: str
|
|
@@ -347,6 +454,8 @@ class PromptFinding:
|
|
|
347
454
|
options: "tuple[PromptOption, ...]"
|
|
348
455
|
excerpt: "tuple[str, ...]"
|
|
349
456
|
pane_hash: str
|
|
457
|
+
matched: "str | None" = None
|
|
458
|
+
source: str = SOURCE_PANE
|
|
350
459
|
|
|
351
460
|
@property
|
|
352
461
|
def selected(self) -> "PromptOption | None":
|
|
@@ -441,6 +550,96 @@ def _excerpt(lines: "list[str]", index: int) -> "tuple[str, ...]":
|
|
|
441
550
|
return tuple(scrub(strip_ansi(ln).rstrip()) for ln in lines[lo:hi])
|
|
442
551
|
|
|
443
552
|
|
|
553
|
+
def _matched_for(kind: str, text: str) -> "str | None":
|
|
554
|
+
"""The `matched` token of a banner finding: the login-expired banner is
|
|
555
|
+
reported as the same signal (`login_expired`), an auth line as the
|
|
556
|
+
wording it carries, every other kind as None."""
|
|
557
|
+
if kind == KIND_LOGIN_EXPIRED:
|
|
558
|
+
return MATCHED_LOGIN_EXPIRED
|
|
559
|
+
if kind == KIND_AUTH_REJECTED:
|
|
560
|
+
return auth_matched(text)
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def classify_exit(pane: str) -> "PromptFinding | None":
|
|
565
|
+
"""The first-turn death check's half of the classifier (issue #146, devloop #140):
|
|
566
|
+
what a pane whose `claude` has already EXITED back to the shell says
|
|
567
|
+
about why, or None for the ordinary "died, not auth".
|
|
568
|
+
|
|
569
|
+
Only ever called on a pane the loop has established is a shell (the
|
|
570
|
+
pane's current command is one of `alissa.SHELL_COMMANDS`, inside the
|
|
571
|
+
first stale window of its round's spawn), so the banner rule --
|
|
572
|
+
the wording must be the LAST thing on screen -- does not apply: the
|
|
573
|
+
shell prompt is the last thing on screen, and the error sits a few
|
|
574
|
+
lines above it. The last EXIT_SCAN_LINES non-blank lines are read
|
|
575
|
+
bottom-up for a documented 401 wording or the login-expired banner; a
|
|
576
|
+
`●` turn row is skipped, and so is EVERY line that belongs to a turn
|
|
577
|
+
(`_under_a_turn`): every line below a `●` row until the next submitted
|
|
578
|
+
prompt row (`>`), paragraph breaks included -- Claude Code renders one
|
|
579
|
+
assistant message as blank-separated paragraphs and code blocks, and
|
|
580
|
+
only the first line carries the `●`. So the reviewer's own prose
|
|
581
|
+
wrapping onto the wording, a quoted error in a paragraph or code block
|
|
582
|
+
of its own, a README quoted under a tool call, a `grep` for it, are
|
|
583
|
+
all the reviewer's -- never Claude Code's error, which is drawn under
|
|
584
|
+
the `>` prompt row (`⎿ API Error: 401 …`) or, on a start-up refusal,
|
|
585
|
+
before any turn exists. The finding is an `auth_rejected` with
|
|
586
|
+
`source: "exit"` and the wording under `matched` -- the same kind the
|
|
587
|
+
pane detector raises, so the policy (page once, hold spawns) is the
|
|
588
|
+
same row."""
|
|
589
|
+
if not pane or not pane.strip():
|
|
590
|
+
return None
|
|
591
|
+
lines = pane.splitlines()
|
|
592
|
+
indices = [i for i, ln in enumerate(lines) if _plain(ln)][-EXIT_SCAN_LINES:]
|
|
593
|
+
for index in reversed(indices):
|
|
594
|
+
raw = strip_ansi(lines[index])
|
|
595
|
+
if _OUTPUT_ROW_RE.match(raw) and not _TOOL_OUTPUT_ROW_RE.match(raw):
|
|
596
|
+
continue
|
|
597
|
+
text = _plain(raw)
|
|
598
|
+
matched = (
|
|
599
|
+
MATCHED_LOGIN_EXPIRED if _LOGIN_EXPIRED_RE.search(text)
|
|
600
|
+
else auth_matched(text)
|
|
601
|
+
)
|
|
602
|
+
if matched is None:
|
|
603
|
+
continue
|
|
604
|
+
if _under_a_turn(lines, index):
|
|
605
|
+
# Any line under a `●` turn or tool call, up to the next `>`
|
|
606
|
+
# prompt row, is that turn's: a `⎿` row is its output (a README
|
|
607
|
+
# quoting the wording, a `grep` for it), a plain row is its own
|
|
608
|
+
# prose -- wrapped onto the wording, or quoting it in a later
|
|
609
|
+
# paragraph or code block. Claude Code's error is never drawn
|
|
610
|
+
# under a `●` without a `>` between them.
|
|
611
|
+
continue
|
|
612
|
+
return PromptFinding(
|
|
613
|
+
kind=KIND_AUTH_REJECTED,
|
|
614
|
+
signature=scrub(text),
|
|
615
|
+
target=None,
|
|
616
|
+
options=(),
|
|
617
|
+
excerpt=_excerpt(lines, index),
|
|
618
|
+
pane_hash=pane_hash(pane),
|
|
619
|
+
matched=matched,
|
|
620
|
+
source=SOURCE_EXIT,
|
|
621
|
+
)
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _under_a_turn(lines: "list[str]", index: int) -> bool:
|
|
626
|
+
"""Whether `lines[index]` belongs to a reviewer's turn: a `●` row sits
|
|
627
|
+
above it, anywhere in the capture, with no submitted-prompt row
|
|
628
|
+
(`_USER_PROMPT_ROW_RE`, the `>` Claude Code draws for the prompt that
|
|
629
|
+
opens the NEXT turn) between them. Blank lines and chrome do NOT end
|
|
630
|
+
a turn (devloop PR #141 review round 2): one assistant message is several
|
|
631
|
+
blank-separated paragraphs and code blocks, `●` on its first line
|
|
632
|
+
only, so the paragraph is no boundary -- only the next prompt row is.
|
|
633
|
+
A `⎿` row above is the turn's output and bounds nothing either."""
|
|
634
|
+
for above in range(index - 1, -1, -1):
|
|
635
|
+
raw = strip_ansi(lines[above])
|
|
636
|
+
if _USER_PROMPT_ROW_RE.match(raw):
|
|
637
|
+
return False
|
|
638
|
+
if _OUTPUT_ROW_RE.match(raw) and not _TOOL_OUTPUT_ROW_RE.match(raw):
|
|
639
|
+
return True
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
|
|
444
643
|
def classify(pane: str) -> "PromptFinding | None":
|
|
445
644
|
"""What this pane is parked on, or None for a pane that is WORKING, idle
|
|
446
645
|
at the input prompt, empty, or unreadable.
|
|
@@ -471,6 +670,7 @@ def classify(pane: str) -> "PromptFinding | None":
|
|
|
471
670
|
index, text = banner
|
|
472
671
|
for kind, pattern in (
|
|
473
672
|
(KIND_LOGIN_EXPIRED, _LOGIN_EXPIRED_RE),
|
|
673
|
+
(KIND_AUTH_REJECTED, _AUTH_REJECTED_RE),
|
|
474
674
|
(KIND_USAGE_LIMIT, _USAGE_LIMIT_RE),
|
|
475
675
|
(KIND_OUT_OF_CREDITS, _OUT_OF_CREDITS_RE),
|
|
476
676
|
):
|
|
@@ -482,6 +682,7 @@ def classify(pane: str) -> "PromptFinding | None":
|
|
|
482
682
|
options=(),
|
|
483
683
|
excerpt=_excerpt(lines, index),
|
|
484
684
|
pane_hash=digest,
|
|
685
|
+
matched=_matched_for(kind, text),
|
|
485
686
|
)
|
|
486
687
|
|
|
487
688
|
parked = _tail_is_dialog(lines)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.31.6
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.31.5
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|