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.
Files changed (38) hide show
  1. {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
  2. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/alissa.py +37 -0
  3. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/loop.py +178 -3
  4. {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
  5. {alissa_tools_github_revloop-0.31.5 → 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.5 → 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.5/src/main/alissa/tools/github/revloop/version +0 -1
  9. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/LICENSE +0 -0
  10. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/MANIFEST.in +0 -0
  11. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/NOTICE +0 -0
  12. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/README.md +0 -0
  13. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/requirements.txt +0 -0
  14. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/setup.cfg +0 -0
  15. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/setup.py +0 -0
  16. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  17. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  18. {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
  19. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  20. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/config.py +0 -0
  21. {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
  22. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  23. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  24. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  25. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/state.py +0 -0
  26. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  27. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.6}/src/main/alissa/tools/github/revloop/version.py +0 -0
  28. {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
  29. {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
  30. {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
  31. {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
  32. {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
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.5
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 --
@@ -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 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
 
@@ -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; `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 "
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` 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.5
3
+ Version: 0.31.6
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera