alissa-tools-github-revloop 0.31.4__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.
Files changed (38) hide show
  1. {alissa_tools_github_revloop-0.31.4/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.6}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/alissa.py +51 -9
  3. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/loop.py +195 -4
  4. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/loop_events.py +139 -2
  5. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/prompts.py +207 -6
  6. alissa_tools_github_revloop-0.31.6/src/main/alissa/tools/github/revloop/version +1 -0
  7. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  8. alissa_tools_github_revloop-0.31.4/src/main/alissa/tools/github/revloop/version +0 -1
  9. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/LICENSE +0 -0
  10. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/MANIFEST.in +0 -0
  11. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/NOTICE +0 -0
  12. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/README.md +0 -0
  13. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/requirements.txt +0 -0
  14. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/setup.cfg +0 -0
  15. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/setup.py +0 -0
  16. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  17. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  18. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  19. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  20. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/config.py +0 -0
  21. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  22. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  23. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  24. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  25. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/state.py +0 -0
  26. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  27. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/version.py +0 -0
  28. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  29. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  30. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  31. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  32. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  33. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  34. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  35. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  36. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  37. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.4 → alissa_tools_github_revloop-0.31.6}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.4
3
+ Version: 0.31.6
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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 --
@@ -647,17 +667,20 @@ class VerdictEnvelope:
647
667
  envelope has no parseable `Merge-Readiness` line -- the case the native
648
668
  post fails closed on. `readiness_reason` is already one bounded,
649
669
  backtick-free line (see clean_readiness_reason); empty when the envelope
650
- gave none. `readiness_class` is the READINESS_CLASSES token an operator
651
- reason leads with (issue #142), or None: for `auto`, for an unclassed
652
- operator reason, and for a missing line alike -- the reason keeps the
653
- prefix either way, so the class is never lost between here and the
654
- native trailer.
670
+ gave none, and it keeps the class prefix an operator reason leads with
671
+ (issue #142) -- so the class is never lost between here and the native
672
+ trailer.
673
+
674
+ The class is deliberately NOT a field. It is a function of the reason
675
+ (classify_readiness_reason), the emitter copies the reason whole, and
676
+ the narration reads the class back off the emitted line -- so the fact
677
+ has one source, and a second copy here would be a reader-less field
678
+ that could only ever drift from it (PR #143 round 1).
655
679
  """
656
680
 
657
681
  verdict: str
658
682
  readiness: "str | None" = None
659
683
  readiness_reason: str = ""
660
- readiness_class: "str | None" = None
661
684
 
662
685
 
663
686
  @dataclass(frozen=True)
@@ -1158,9 +1181,12 @@ class Alissa:
1158
1181
  continue
1159
1182
  match = _VERDICT_RE.search(blob)
1160
1183
  if match:
1161
- readiness, reason, klass = parse_readiness(content)
1184
+ # The class the triple carries is not stored: the
1185
+ # reason keeps its prefix, and the envelope's readers
1186
+ # re-derive the class from the line they emit.
1187
+ readiness, reason, _ = parse_readiness(content)
1162
1188
  if readiness is None:
1163
- readiness, reason, klass = parse_readiness(title)
1189
+ readiness, reason, _ = parse_readiness(title)
1164
1190
  found.append(
1165
1191
  (Alissa._created_key(item.get("createdAt")),
1166
1192
  index,
@@ -1168,7 +1194,6 @@ class Alissa:
1168
1194
  verdict=match.group(1).lower(),
1169
1195
  readiness=readiness,
1170
1196
  readiness_reason=reason,
1171
- readiness_class=klass,
1172
1197
  ))
1173
1198
  )
1174
1199
  break
@@ -1374,6 +1399,23 @@ class Alissa:
1374
1399
  log.debug("could not read the pane path of %s (%s)", name, exc)
1375
1400
  return ""
1376
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
+
1377
1419
  def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
1378
1420
  """Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
1379
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 LoopEventsEmitter, build_emitter
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
 
@@ -713,6 +720,16 @@ _RECORD_THE_CAP = (
713
720
  # reviewer needs to classify honestly: an unverified-* hold is validation
714
721
  # work, not merge risk, and saying so lets the policy merge it. The table's
715
722
  # rows are the README's, condensed; the README is the long form.
723
+ #
724
+ # The table is closed, so the directive must also say what to do when NO
725
+ # row fits (PR #143 round 1): the skill's checklist has operator triggers
726
+ # the enum does not name (dependencies, a deleted public surface, a waived
727
+ # `[major]`, a judgment residual, an operator gate that is a human action).
728
+ # A reviewer told the class is mandatory would shoehorn such a reason into
729
+ # the nearest row, and the nearest row for a vague reason is one of the
730
+ # `unverified-*` pair -- exactly what the policy may merge unattended. So
731
+ # the directive names the way out: no row, no class, and unclassed is the
732
+ # correct hard hold, never a failure.
716
733
  _MERGE_READINESS_LINE = (
717
734
  "Your verdict envelope MUST carry the skill's `- **Merge-Readiness:** auto | "
718
735
  "operator — <class>: <reason>` line, AND your OWN native review (every `gh pr "
@@ -742,7 +759,13 @@ _MERGE_READINESS_LINE = (
742
759
  "`release-act` (the merge itself is a release — VERSION bump → publish, tag, "
743
760
  "npm). "
744
761
  "When more than one row applies, name the MOST SEVERE one — the row listed "
745
- "first — and only it. Classify honestly, not defensively: an unverified-* "
762
+ "first — and only it. If NO row fits — a dependency change, a deleted or "
763
+ "renamed public surface, a waived `[major]`, a judgment residual, an "
764
+ "operator gate that is a human ACTION rather than a look or a smoke run — "
765
+ "write the reason with NO class: an unclassed operator line is the correct "
766
+ "hard hold, never a failure, and shoehorning such a reason into the nearest "
767
+ "row is the one misclassification the merge policy cannot recover from. "
768
+ "Classify honestly, not defensively: an unverified-* "
746
769
  "hold is validation work the human does at gate 2 anyway, not merge risk, "
747
770
  "and the merge policy may merge it while it keeps holding every row above; "
748
771
  "an operator line whose reason leads with no recognised token still parses "
@@ -1314,7 +1337,11 @@ PROMPT_PAGE_COMMENT = (
1314
1337
  "not answering it, and it is HOLDING new reviewer spawns until the "
1315
1338
  "condition clears (live sessions keep running; the rounds they owe are "
1316
1339
  "not consumed). Operator options: `login_expired` → re-run the claude "
1317
- "login on the reviewer container; `usage_limit` → wait for the reset or "
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 "
1318
1345
  "raise the plan; `out_of_credits` → top up. The hold lifts on the first "
1319
1346
  "poll that no longer sees the notice and in any case after {hold_minutes} "
1320
1347
  "min on the same pane: the session is then killed and the stale-round "
@@ -1341,6 +1368,12 @@ ESCALATION_PROMPT_PAGE = "prompt-page"
1341
1368
  # slow container.
1342
1369
  PROMPT_SETTLE_SECONDS = 3.0
1343
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
+
1344
1377
 
1345
1378
  def prompt_event_kind(kind: str, verb: str, session: str, seq: int) -> str:
1346
1379
  """The ping kind of ONE responder act: `prompt:<kind>:<verb>@<session>#<seq>`.
@@ -2125,6 +2158,26 @@ class ReviewWatcher:
2125
2158
  self._prompt_hold: "str | None" = None
2126
2159
  # What the responder found and did this pass, for the summary line.
2127
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()
2128
2181
  # The settle wait between pressing a dialog's keys and re-capturing
2129
2182
  # the pane; injectable so a test never sleeps.
2130
2183
  self._sleep: "Callable[[float], None]" = time.sleep
@@ -5726,6 +5779,18 @@ class ReviewWatcher:
5726
5779
  answered = 0
5727
5780
  for ses in sessions:
5728
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
5729
5794
  marker = self._waiting_marker(ses.tmux_name)
5730
5795
  quiet = ses.quiet_for
5731
5796
  if marker is None and (
@@ -5756,8 +5821,122 @@ class ReviewWatcher:
5756
5821
  hold = outcome["hold"]
5757
5822
  self._sighting_prune(live)
5758
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]
5759
5826
  return PromptSweep(waiting=tuple(waiting), hold=hold, answered=answered)
5760
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
+
5761
5940
  def _answer_prompt(
5762
5941
  self,
5763
5942
  ses: ManagedSession,
@@ -5883,10 +6062,16 @@ class ReviewWatcher:
5883
6062
  tail = " · killed; the stale-round edge re-enters the same round next poll"
5884
6063
  elif action.sends_keys and not sent and not self.config.dry_run:
5885
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
+ )
5886
6071
  line = self._activity_line(
5887
6072
  name, anchor.round,
5888
6073
  f"{prefix}: {finding.kind} → {action.verb} ({action.reason})"
5889
- f"{f' · target {target_rel}' if target_rel else ''}"
6074
+ f"{f' · target {target_rel}' if target_rel else ''}{auth_tail}"
5890
6075
  f" · {since} s after it appeared{tail}",
5891
6076
  anchor.cap,
5892
6077
  )
@@ -5904,6 +6089,12 @@ class ReviewWatcher:
5904
6089
  "spawn row, and the name resolves to no single watched repo) — "
5905
6090
  "%s → %s recorded in the log only", name, finding.kind, action.verb,
5906
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)
5907
6098
  return {
5908
6099
  "waiting": {
5909
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` and `checks.held` out of the ping ledger.
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((KIND_LOGIN_EXPIRED, KIND_USAGE_LIMIT, KIND_OUT_OF_CREDITS))
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; otherwise the
299
- paragraph's last BANNER_LINES lines are the candidates."""
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 (_LOGIN_EXPIRED_RE, _USAGE_LIMIT_RE, _OUT_OF_CREDITS_RE):
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.4
3
+ Version: 0.31.6
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera