alissa-tools-github-devloop 0.8.29__tar.gz → 0.8.30__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_devloop-0.8.29/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.30}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/alissa.py +37 -0
  3. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/loop.py +154 -5
  4. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/loop_events.py +116 -3
  5. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/prompts.py +207 -6
  6. alissa_tools_github_devloop-0.8.30/src/main/alissa/tools/github/devloop/version +1 -0
  7. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  8. alissa_tools_github_devloop-0.8.29/src/main/alissa/tools/github/devloop/version +0 -1
  9. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/LICENSE +0 -0
  10. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/MANIFEST.in +0 -0
  11. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/NOTICE +0 -0
  12. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/README.md +0 -0
  13. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/requirements.txt +0 -0
  14. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/setup.cfg +0 -0
  15. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/setup.py +0 -0
  16. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  17. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  18. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  19. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  20. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/config.py +0 -0
  21. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
  22. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  23. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  24. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  25. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  26. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/state.py +0 -0
  27. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/version.py +0 -0
  28. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  29. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  30. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  31. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  32. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  33. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  34. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  35. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  36. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  37. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  38. {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.29
3
+ Version: 0.8.30
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -80,6 +80,26 @@ def pane_path_argv(name: str) -> "list[str]":
80
80
  ]
81
81
 
82
82
 
83
+ # What `#{pane_current_command}` reads on a worker pane whose `claude` has
84
+ # EXITED (issue #140): the shell `alissa tmux new` launched it from, back in
85
+ # the foreground. A live worker reads `node` (Claude Code's runtime); a
86
+ # name here means the process the session was spawned for is gone, whatever
87
+ # the session listing says about liveness.
88
+ SHELL_COMMANDS = frozenset((
89
+ "bash", "sh", "zsh", "fish", "dash", "ksh", "ash", "tcsh", "csh",
90
+ ))
91
+
92
+
93
+ def pane_command_argv(name: str) -> "list[str]":
94
+ """`tmux -S <socket> display-message -p -t =<real>: #{pane_current_command}`:
95
+ the foreground process of the session's active pane, by name."""
96
+ check_session_name(name)
97
+ return [
98
+ "tmux", "-S", tmux_socket(), "display-message", "-p",
99
+ "-t", tmux_target(name), "#{pane_current_command}",
100
+ ]
101
+
102
+
83
103
  def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
84
104
  """`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
85
105
  from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
@@ -270,6 +290,23 @@ class Alissa:
270
290
  log.debug("could not read the pane path of %s (%s)", name, exc)
271
291
  return ""
272
292
 
293
+ def pane_command(self, name: str) -> str:
294
+ """The session's pane current command (`#{pane_current_command}`),
295
+ or "" when tmux cannot answer. The first-turn death check's one
296
+ liveness probe (issue #140): a worker whose pane reads a shell
297
+ (`SHELL_COMMANDS`) has lost its `claude`, however alive the tmux
298
+ session itself is. A READ: runs under dry-run too."""
299
+ try:
300
+ argv = pane_command_argv(name)
301
+ except ValueError as exc:
302
+ log.warning("pane_command refused: %s", exc)
303
+ return ""
304
+ try:
305
+ return run(argv, timeout=15).strip()
306
+ except CommandError as exc:
307
+ log.debug("could not read the pane command of %s (%s)", name, exc)
308
+ return ""
309
+
273
310
  def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
274
311
  """Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
275
312
  -- on ONE managed session's pane. True when tmux accepted them.
@@ -464,7 +464,7 @@ from . import janitor
464
464
  from . import fleet_vitals as fleet_vitals_mod
465
465
  from . import loop_events as loop_events_mod
466
466
  from . import prompts as prompts_mod
467
- from .alissa import MANAGED_PREFIX, Alissa
467
+ from .alissa import MANAGED_PREFIX, SHELL_COMMANDS, Alissa
468
468
  from .alissa_client import AlissaClient
469
469
  from .bows import BowRepoSource
470
470
  from .hubs import (
@@ -1268,6 +1268,10 @@ PROMPT_PAGE_COMMENT = (
1268
1268
  "not answering it, and it is HOLDING new worker spawns until the "
1269
1269
  "condition clears (live sessions keep running). Operator options: "
1270
1270
  "`login_expired` → re-run the claude login on the worker container; "
1271
+ "`auth_rejected` → Claude refused the worker's credential (401): replace "
1272
+ "the container's token (`ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN`) "
1273
+ "or re-run the claude login — the `auth.rejected` loop event names the "
1274
+ "variable and the token's last four characters; "
1271
1275
  "`usage_limit` → wait for the reset or raise the plan; `out_of_credits` "
1272
1276
  "→ top up. The hold lifts on the first poll that no longer sees the "
1273
1277
  "notice and in any case after {hold_minutes} min on the same pane: the "
@@ -1575,6 +1579,11 @@ ESCALATION_PROMPT_PAGE = "prompt-page"
1575
1579
  # a slow container.
1576
1580
  PROMPT_SETTLE_SECONDS = 3.0
1577
1581
 
1582
+ # The first-turn death check's "never looked" marker (issue #140): the
1583
+ # cache maps a session to a finding, to None ("looked: died, not auth"),
1584
+ # or -- absent -- to this, so the two negatives cannot be confused.
1585
+ _UNCHECKED = object()
1586
+
1578
1587
 
1579
1588
  def prompt_event_kind(kind: str, verb: str, session: str, seq: int) -> str:
1580
1589
  """The ledger kind of ONE responder act: `prompt:<kind>:<verb>@<session>#<seq>`.
@@ -2482,6 +2491,11 @@ class Sweep:
2482
2491
  waiting: "tuple[dict, ...]" = ()
2483
2492
  hold: "str | None" = None
2484
2493
  answered: int = 0
2494
+ # The refusals the responder observed this pass (issue #140): the
2495
+ # `auth.rejected` records the loop-events emitter turns into events,
2496
+ # one per session (`{session, repo, edge, number, attempt, round,
2497
+ # source, matched}`).
2498
+ auth_rejected: "tuple[dict, ...]" = ()
2485
2499
 
2486
2500
  @property
2487
2501
  def total(self) -> int:
@@ -2493,12 +2507,14 @@ class Sweep:
2493
2507
  class PromptSweep:
2494
2508
  """What ONE prompt-responder pass found and did (issue #127): the
2495
2509
  sessions parked on a prompt (`{session, kind, since, action}`), the
2496
- spawn hold an account-level notice raised, and the keystroke answers
2497
- sent. Folded into the pass's `Sweep`."""
2510
+ spawn hold an account-level notice raised, the keystroke answers
2511
+ sent, and the `auth.rejected` records (issue #140). Folded into the
2512
+ pass's `Sweep`."""
2498
2513
 
2499
2514
  waiting: "tuple[dict, ...]" = ()
2500
2515
  hold: "str | None" = None
2501
2516
  answered: int = 0
2517
+ auth_rejected: "tuple[dict, ...]" = ()
2502
2518
 
2503
2519
 
2504
2520
  @dataclass(frozen=True, slots=True)
@@ -3247,6 +3263,16 @@ class DevWatcher:
3247
3263
  # rebuilt only when the allowlist itself changes rather than per
3248
3264
  # session per sweep.
3249
3265
  self._slug_repos = _slug_index(config.repos)
3266
+ # The first-turn death check's memory (issue #140): every develop
3267
+ # session whose pane was found back in a shell inside its first
3268
+ # stale window, mapped to what its last lines said -- an
3269
+ # `auth_rejected` finding, or None for the ordinary "died, not
3270
+ # auth". The pane is captured ONCE per session; later passes
3271
+ # re-assert a cached finding (so the hold and its expiry run the
3272
+ # login_expired ladder) and never re-read a None. In-memory on
3273
+ # purpose: a restart re-checks once, and the loop event's key is
3274
+ # the session name, so a re-observation is a silent duplicate.
3275
+ self._exit_findings: "dict[str, prompts_mod.PromptFinding | None]" = {}
3250
3276
  # The janitor's two pieces of per-process state: when its expensive
3251
3277
  # half last ran (monotonic; None = never, so the first pass of a
3252
3278
  # process always sweeps) and the last per-hub breakdown it walked,
@@ -8589,6 +8615,7 @@ class DevWatcher:
8589
8615
  waiting: "list[dict]" = []
8590
8616
  hold: "str | None" = None
8591
8617
  answered = 0
8618
+ rejected: "list[dict]" = []
8592
8619
  for entry in sessions:
8593
8620
  name = str(entry.get("name") or "")
8594
8621
  worker = parse_worker_session(name)
@@ -8596,6 +8623,20 @@ class DevWatcher:
8596
8623
  continue
8597
8624
  live.add(name)
8598
8625
  tmux_name = str(entry.get("session") or "") or MANAGED_PREFIX + name
8626
+ # The first-turn death check (issue #140) runs BEFORE the two
8627
+ # triggers below: a pane whose `claude` exited is a shell at
8628
+ # rest, which neither a marker nor the quiet gate reads as a
8629
+ # prompt, and which the banner classifier would call idle.
8630
+ exited = self._first_turn_death(worker, name)
8631
+ if exited is not None:
8632
+ outcome = self._answer_prompt(worker, tmux_name, None, exited, mode)
8633
+ waiting.append(outcome["waiting"])
8634
+ answered += outcome["answered"]
8635
+ if outcome["hold"] and hold is None:
8636
+ hold = outcome["hold"]
8637
+ if outcome["auth"]:
8638
+ rejected.append(outcome["auth"])
8639
+ continue
8599
8640
  marker = self._waiting_marker(tmux_name)
8600
8641
  quiet = self._entry_quiet_for(entry)
8601
8642
  if marker is None and (
@@ -8624,8 +8665,88 @@ class DevWatcher:
8624
8665
  answered += outcome["answered"]
8625
8666
  if outcome["hold"] and hold is None:
8626
8667
  hold = outcome["hold"]
8668
+ if outcome["auth"]:
8669
+ rejected.append(outcome["auth"])
8627
8670
  self.state.prune_prompt_sightings(live)
8628
- return PromptSweep(waiting=tuple(waiting), hold=hold, answered=answered)
8671
+ for gone in [n for n in self._exit_findings if n not in live]:
8672
+ del self._exit_findings[gone]
8673
+ return PromptSweep(
8674
+ waiting=tuple(waiting), hold=hold, answered=answered,
8675
+ auth_rejected=tuple(rejected),
8676
+ )
8677
+
8678
+ def _first_turn_death(
8679
+ self, worker: WorkerSession, name: str
8680
+ ) -> "prompts_mod.PromptFinding | None":
8681
+ """The first-turn death check (issue #140): an `auth_rejected`
8682
+ finding with `source: "exit"` for a develop session whose `claude`
8683
+ died on a 401 before it could do anything, else None.
8684
+
8685
+ The candidate is narrow by design, and every leg is cheap or rare:
8686
+ a DEVELOP session (the only edge whose lane has no PR until the
8687
+ worker opens one) that this ledger spawned inside the last stale
8688
+ window, whose pane's current command is back in `SHELL_COMMANDS`
8689
+ (one `display-message` per young develop session per pass -- a
8690
+ handful, for twenty minutes), with no open PR linked to its issue
8691
+ (one timeline read, only for a dead shell). Its last lines are
8692
+ captured ONCE and read by `prompts.classify_exit`; a match is
8693
+ remembered and re-asserted on later passes while the pane is still
8694
+ a shell, so the page-and-hold path and its one-hour expiry run
8695
+ exactly as they do for a login_expired banner. No match is the
8696
+ ordinary "died, not auth": remembered too (never re-read), and
8697
+ nothing new is emitted -- the stale-retry edge's respawn is the
8698
+ remedy it always was."""
8699
+ cached = self._exit_findings.get(name, _UNCHECKED)
8700
+ if cached is None:
8701
+ return None
8702
+ if cached is not _UNCHECKED:
8703
+ command = self.alissa.pane_command(name)
8704
+ if command in SHELL_COMMANDS or not command:
8705
+ # Still the dead shell -- or tmux could not answer ("" is
8706
+ # a failed read, never a command), in which case the last
8707
+ # thing known stands: forgetting it here would re-arm a
8708
+ # second capture inside the stale window and drop the
8709
+ # hold and its expiry past it, for a shell still dead.
8710
+ return cached # type: ignore[return-value]
8711
+ # A named, non-shell command: someone re-ran claude by hand in
8712
+ # the session -- a live pane again, and the ordinary triggers
8713
+ # own it from here.
8714
+ del self._exit_findings[name]
8715
+ return None
8716
+ if not worker.is_issue:
8717
+ return None
8718
+ row = self.state.spawn_for_session(name)
8719
+ if row is None:
8720
+ return None
8721
+ if time.time() - int(row["spawned_at"]) >= self.config.stale_minutes * 60:
8722
+ return None
8723
+ if self.alissa.pane_command(name) not in SHELL_COMMANDS:
8724
+ return None
8725
+ repo = self._slug_repos.get(worker.slug)
8726
+ if repo is None:
8727
+ return None
8728
+ try:
8729
+ linked = self.github.linked_open_prs(repo[0], repo[1], worker.number)
8730
+ except Exception as exc:
8731
+ log.debug(
8732
+ "first-turn death check: could not read the PRs linked to %s#%d "
8733
+ "(%s) — deferring the check to the next pass",
8734
+ row["repo_slug"], worker.number, exc,
8735
+ )
8736
+ return None
8737
+ if linked:
8738
+ return None
8739
+ pane = self.alissa.capture_pane(name, prompts_mod.PANE_LINES)
8740
+ finding = prompts_mod.classify_exit(pane)
8741
+ self._exit_findings[name] = finding
8742
+ if finding is None:
8743
+ log.info(
8744
+ "first-turn death check: %s exited to a shell %d s after its "
8745
+ "spawn with no linked PR and no auth wording in its last lines "
8746
+ "— died, not auth; the retry edge owns it",
8747
+ name, int(time.time() - int(row["spawned_at"])),
8748
+ )
8749
+ return finding
8629
8750
 
8630
8751
  def _answer_prompt(
8631
8752
  self,
@@ -8770,10 +8891,16 @@ class DevWatcher:
8770
8891
  tail = " · killed; the ordinary retry edge respawns"
8771
8892
  elif action.sends_keys and not sent and not self.config.dry_run:
8772
8893
  tail = " · keys not sent (tmux refused)"
8894
+ # The auth kinds name the wording and where it was read (the
8895
+ # `auth.rejected` event's own tokens, never the pane's text).
8896
+ auth_tail = (
8897
+ f" · matched {finding.matched} · source {finding.source}"
8898
+ if finding.kind in prompts_mod.AUTH_KINDS and finding.matched else ""
8899
+ )
8773
8900
  line = self._activity_line(
8774
8901
  name, f"{worker.edge} edge",
8775
8902
  f"{prefix}: {finding.kind} → {action.verb} ({action.reason})"
8776
- f"{f' · target {target_rel}' if target_rel else ''}"
8903
+ f"{f' · target {target_rel}' if target_rel else ''}{auth_tail}"
8777
8904
  f" · {since} s after it appeared{tail}",
8778
8905
  )
8779
8906
  self._append_activity(owner, repo_name, worker.number, line, f"{slug}#{worker.number}")
@@ -8787,6 +8914,25 @@ class DevWatcher:
8787
8914
  "prompt responder: %s (%s) is not on the allowlist — %s → %s "
8788
8915
  "recorded in the log only", name, worker.slug, finding.kind, action.verb,
8789
8916
  )
8917
+ # The `auth.rejected` record (issue #140): the two AUTH_KINDS are
8918
+ # the same signal, and the record carries the vocabulary tokens and
8919
+ # the lane's coordinates -- never the signature. Live mode only:
8920
+ # `observe` pages nobody and holds nothing, and Studio acts on this
8921
+ # event (it flips the stored credential), so an observing daemon
8922
+ # must not make that claim either.
8923
+ auth: "dict | None" = None
8924
+ if finding.kind in prompts_mod.AUTH_KINDS and live_mode:
8925
+ auth = {
8926
+ "session": name,
8927
+ "edge": "issue" if worker.is_issue else "pr",
8928
+ "number": worker.number,
8929
+ "attempt": worker.attempt,
8930
+ "round": worker.round_,
8931
+ "source": finding.source,
8932
+ "matched": finding.matched or prompts_mod.MATCHED_API_ERROR_401,
8933
+ }
8934
+ if repo:
8935
+ auth["repo"] = slug
8790
8936
  return {
8791
8937
  "waiting": {
8792
8938
  "session": name, "kind": finding.kind,
@@ -8795,6 +8941,7 @@ class DevWatcher:
8795
8941
  },
8796
8942
  "answered": 1 if sent else 0,
8797
8943
  "hold": hold,
8944
+ "auth": auth,
8798
8945
  }
8799
8946
 
8800
8947
  def _page_prompt(
@@ -8985,6 +9132,7 @@ class DevWatcher:
8985
9132
  prompt = self._respond_to_prompts(sessions, sweep.names)
8986
9133
  sweep = replace(
8987
9134
  sweep, waiting=prompt.waiting, hold=prompt.hold, answered=prompt.answered,
9135
+ auth_rejected=prompt.auth_rejected,
8988
9136
  )
8989
9137
  if sweep.hold:
8990
9138
  log.warning(
@@ -10050,6 +10198,7 @@ class DevWatcher:
10050
10198
  self.loop_events.emit_pass(
10051
10199
  deferrals=deferrals,
10052
10200
  reaps=self._reap_records(sweep.names),
10201
+ auth_rejections=list(sweep.auth_rejected),
10053
10202
  now_ms=int(time.time() * 1000),
10054
10203
  )
10055
10204
 
@@ -69,6 +69,22 @@ Event vocabulary (the `kind` field; Studio validates the SHAPE
69
69
  `escalation.stacked_base`, `escalation.foreign_session`, …): Studio's kind
70
70
  shape allows no hyphens, so the raw ledger kind rides in ``data.ledgerKind``
71
71
  and `reason` instead.
72
+ * ``auth.rejected`` — a worker REFUSED by Claude (issue #140; Studio design
73
+ `managed-dark-factory-claude-auth.md` §2.6, lane L5): the prompt responder
74
+ saw a documented 401 wording (or the login-expired banner) on a live
75
+ pane, or the first-turn death check found one in the last lines of a pane
76
+ whose `claude` had already exited. Pass-observed and keyed on the SESSION
77
+ name (`auth_rejected_key`), so one session emits at most one whatever
78
+ the pass count. ``data: { status: 401, source: "pane" | "exit", matched,
79
+ envName, tokenSuffix }`` — `envName` is which of `ANTHROPIC_API_KEY` /
80
+ `CLAUDE_CODE_OAUTH_TOKEN` the container carries, read BY NAME ONLY (`null`
81
+ on a persisted `claude /login`); `tokenSuffix` is the last four characters
82
+ of that variable's value, computed in-process and emitted as the suffix
83
+ only (`null` beside a `null` envName). The suffix is the design's R1 key:
84
+ Studio's reducer flips a credential only when the suffix matches the
85
+ stored row, so a container still running an OLD token after a same-mode
86
+ replace cannot flip the new, good credential. `reason` is a fixed
87
+ sentence, never the pane's text.
72
88
 
73
89
  No ``poll`` vitals events: live vitals stay a console pull (`/api/state`).
74
90
  """
@@ -76,6 +92,7 @@ No ``poll`` vitals events: live vitals stay a console pull (`/api/state`).
76
92
  from __future__ import annotations
77
93
 
78
94
  import logging
95
+ import os
79
96
  import re
80
97
  from typing import Iterable, Mapping, Sequence
81
98
 
@@ -253,6 +270,14 @@ def reap_key(session: str) -> str:
253
270
  return f"{SEAT}:reap:{session}"
254
271
 
255
272
 
273
+ def auth_rejected_key(session: str) -> str:
274
+ """One `auth.rejected` per SESSION (issue #140): worker names are
275
+ deterministic per (repo, number, attempt) and a session is refused
276
+ once -- the pane detector seeing the same banner on every pass, or the
277
+ exit check re-asserting a dead shell, is the same refusal."""
278
+ return f"{SEAT}:auth.rejected:{session}"
279
+
280
+
256
281
  def escalation_key(event_kind: str, repo_slug: str, number: int,
257
282
  raw_kind: str) -> str:
258
283
  """Escalation-derived events key on the ledger PRIMARY KEY verbatim
@@ -494,6 +519,83 @@ def reap_events(reaps: "Sequence[Mapping]", at_ms: int) -> "list[dict]":
494
519
  return events
495
520
 
496
521
 
522
+ # -- auth.rejected (issue #140) ---------------------------------------------
523
+
524
+ # The credential variables a container may carry, in Claude Code's own
525
+ # precedence order (design §1.4: `ANTHROPIC_API_KEY` outranks
526
+ # `CLAUDE_CODE_OAUTH_TOKEN`; both outrank the persisted login). Read by
527
+ # NAME only -- the value is touched for its last four characters and for
528
+ # nothing else, and never leaves this function whole.
529
+ AUTH_ENV_NAMES = ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN")
530
+ TOKEN_SUFFIX_CHARS = 4
531
+ AUTH_STATUS = 401
532
+
533
+ # The fixed `reason` sentences -- never the pane's text (the contract's
534
+ # `reason` is ≤ 2000 characters, and a pane is another agent's screen).
535
+ AUTH_REASONS = {
536
+ "pane": "claude answered 401 — the worker's pane shows an auth banner",
537
+ "exit": "claude answered 401 on the first turn and exited",
538
+ }
539
+
540
+
541
+ def credential_identity(
542
+ environ: "Mapping[str, str] | None" = None,
543
+ ) -> "tuple[str | None, str | None]":
544
+ """`(envName, tokenSuffix)` for the container this daemon runs in: the
545
+ first of AUTH_ENV_NAMES that is set and non-blank, and the last
546
+ TOKEN_SUFFIX_CHARS characters of its value; `(None, None)` when neither
547
+ is set -- a container running on a persisted `claude /login`. The
548
+ workers are spawned on this same container by `alissa tmux`, so the
549
+ daemon's environment IS theirs."""
550
+ env = os.environ if environ is None else environ
551
+ for name in AUTH_ENV_NAMES:
552
+ value = str(env.get(name) or "").strip()
553
+ if value:
554
+ return name, value[-TOKEN_SUFFIX_CHARS:]
555
+ return None, None
556
+
557
+
558
+ def auth_rejected_events(
559
+ rejections: "Sequence[Mapping]",
560
+ at_ms: int,
561
+ environ: "Mapping[str, str] | None" = None,
562
+ ) -> "list[dict]":
563
+ """Pass-observed refusals as events. Each entry carries `session`,
564
+ `source` (`pane` / `exit`), `matched` (one of the design's tokens),
565
+ and -- when the session resolved to a repo -- `repo`, `edge`
566
+ (`issue`/`pr`), `number`, `attempt`, `round`. Keyed on the session
567
+ name alone (see `auth_rejected_key`). `data` is the design's §2.6
568
+ shape, `envName` and `tokenSuffix` from `credential_identity`."""
569
+ env_name, suffix = credential_identity(environ)
570
+ events = []
571
+ for item in rejections:
572
+ edge = str(item.get("edge") or "")
573
+ number = item.get("number")
574
+ source = str(item.get("source") or "pane")
575
+ events.append(_event(
576
+ "auth.rejected",
577
+ at_ms,
578
+ auth_rejected_key(str(item["session"])),
579
+ repo=str(item.get("repo") or "") or None,
580
+ issue_number=int(number) if number is not None and edge == "issue"
581
+ else None,
582
+ pr_number=int(number) if number is not None and edge == "pr"
583
+ else None,
584
+ attempt=item.get("attempt"),
585
+ round_=item.get("round"),
586
+ session=str(item["session"]),
587
+ reason=AUTH_REASONS.get(source, AUTH_REASONS["pane"]),
588
+ data={
589
+ "status": AUTH_STATUS,
590
+ "source": source,
591
+ "matched": str(item.get("matched") or "api_error_401"),
592
+ "envName": env_name,
593
+ "tokenSuffix": suffix,
594
+ },
595
+ ))
596
+ return events
597
+
598
+
497
599
  # -- the emitter -------------------------------------------------------------
498
600
 
499
601
 
@@ -513,9 +615,16 @@ class LoopEventEmitter:
513
615
  with its flag intact.
514
616
  """
515
617
 
516
- def __init__(self, state, client: "AlissaClient"):
618
+ def __init__(
619
+ self, state, client: "AlissaClient",
620
+ environ: "Mapping[str, str] | None" = None,
621
+ ):
517
622
  self.state = state
518
623
  self.client = client
624
+ # Where `auth.rejected` reads the credential's name and suffix
625
+ # (issue #140): the process environment by default, injectable so a
626
+ # test never depends on -- or leaks -- the seat's own token.
627
+ self.environ = environ
519
628
  self._emitted: "set[str]" = set()
520
629
  self._observed: "dict[str, dict]" = {}
521
630
  self._resume_keys: "set[str]" = set()
@@ -530,6 +639,7 @@ class LoopEventEmitter:
530
639
  *,
531
640
  deferrals: "Sequence[Mapping]" = (),
532
641
  reaps: "Sequence[Mapping]" = (),
642
+ auth_rejections: "Sequence[Mapping]" = (),
533
643
  now_ms: int,
534
644
  ) -> int:
535
645
  """Derive this pass's events and POST what the server has not yet
@@ -538,10 +648,13 @@ class LoopEventEmitter:
538
648
  Returns how many events were accepted this pass. NEVER raises: any
539
649
  failure — a wedged ledger read, a transport error, an ingest 400 —
540
650
  logs one warning naming the count (and status, when there is one)
541
- and the pass completes."""
651
+ and the pass completes. `auth_rejections` (issue #140) are
652
+ pass-observed like the reaps: carried until a POST lands, first
653
+ observation per session wins."""
542
654
  try:
543
655
  for event in (defer_events(deferrals, now_ms)
544
- + reap_events(reaps, now_ms)):
656
+ + reap_events(reaps, now_ms)
657
+ + auth_rejected_events(auth_rejections, now_ms, self.environ)):
545
658
  self._observed.setdefault(str(event["dedupeKey"]), event)
546
659
  events = (
547
660
  spawn_events(
@@ -50,6 +50,12 @@ KIND_PERMISSION = "permission"
50
50
  KIND_TRUST = "trust"
51
51
  KIND_RESUME_PICKER = "resume_picker"
52
52
  KIND_LOGIN_EXPIRED = "login_expired"
53
+ # A 401 from the API on a worker's turn (issue #140, Studio design
54
+ # managed-dark-factory-claude-auth §2.6): the credential the container
55
+ # carries -- an env token or the persisted login -- was REFUSED. Same
56
+ # family as login_expired (an account-level notice: page, hold spawns, no
57
+ # keys) and the same loop-event signal (`auth.rejected`, see `matched`).
58
+ KIND_AUTH_REJECTED = "auth_rejected"
53
59
  KIND_USAGE_LIMIT = "usage_limit"
54
60
  KIND_OUT_OF_CREDITS = "out_of_credits"
55
61
  KIND_UNKNOWN_DIALOG = "unknown_dialog"
@@ -60,6 +66,7 @@ KINDS = (
60
66
  KIND_TRUST,
61
67
  KIND_RESUME_PICKER,
62
68
  KIND_LOGIN_EXPIRED,
69
+ KIND_AUTH_REJECTED,
63
70
  KIND_USAGE_LIMIT,
64
71
  KIND_OUT_OF_CREDITS,
65
72
  KIND_UNKNOWN_DIALOG,
@@ -68,7 +75,38 @@ KINDS = (
68
75
  # The account-level kinds: nothing a keystroke can fix. The policy pages the
69
76
  # operator and holds NEW spawns while the condition stands, so attempts are
70
77
  # not burned against a dead account.
71
- ACCOUNT_KINDS = frozenset((KIND_LOGIN_EXPIRED, KIND_USAGE_LIMIT, KIND_OUT_OF_CREDITS))
78
+ ACCOUNT_KINDS = frozenset((
79
+ KIND_LOGIN_EXPIRED, KIND_AUTH_REJECTED, KIND_USAGE_LIMIT, KIND_OUT_OF_CREDITS,
80
+ ))
81
+
82
+ # The two kinds that ARE the `auth.rejected` loop event (issue #140): a
83
+ # refused credential, whichever wording Claude Code chose for it. The
84
+ # existing login_expired banner is the same signal under its old name.
85
+ AUTH_KINDS = frozenset((KIND_LOGIN_EXPIRED, KIND_AUTH_REJECTED))
86
+
87
+ # -- the auth.rejected vocabulary (design §2.6) ---------------------------
88
+
89
+ # `data.matched` -- WHICH documented wording the pane showed. A closed list,
90
+ # settled in the design before any daemon emits it; Studio's reducer reads
91
+ # the suffix, not this, so the token is for the operator's eyes.
92
+ MATCHED_API_ERROR_401 = "api_error_401"
93
+ MATCHED_INVALID_API_KEY = "invalid_api_key"
94
+ MATCHED_OAUTH_EXPIRED = "oauth_expired"
95
+ MATCHED_OAUTH_REVOKED = "oauth_revoked"
96
+ MATCHED_LOGIN_EXPIRED = "login_expired"
97
+ AUTH_MATCHED = (
98
+ MATCHED_API_ERROR_401,
99
+ MATCHED_INVALID_API_KEY,
100
+ MATCHED_OAUTH_EXPIRED,
101
+ MATCHED_OAUTH_REVOKED,
102
+ MATCHED_LOGIN_EXPIRED,
103
+ )
104
+
105
+ # `data.source` -- where the detector read the wording: a still-open pane
106
+ # (the banner classifier) or the last lines of a pane whose `claude`
107
+ # already exited back to the shell (the first-turn death check).
108
+ SOURCE_PANE = "pane"
109
+ SOURCE_EXIT = "exit"
72
110
 
73
111
  # -- keys ----------------------------------------------------------------
74
112
 
@@ -122,6 +160,16 @@ BANNER_LINES = 3
122
160
  # excerpt the issue allows: three lines, secrets-scrubbed, never more).
123
161
  EXCERPT_LINES = 3
124
162
 
163
+ # How many non-blank lines from the bottom the first-turn death check reads
164
+ # (issue #140). A `claude` that 401s prints the error and exits; the shell
165
+ # then draws its prompt (and, on some shells, a blank line or two) under it,
166
+ # so the wording sits a few lines above the bottom -- never as the LAST
167
+ # thing on screen, which is why the banner rule cannot see it. Twelve lines
168
+ # hold the error, the JSON body Claude Code sometimes wraps it in, and the
169
+ # shell chrome, and nothing older: the scrollback above is the worker's own
170
+ # turn, where a quoted phrase would be a false positive.
171
+ EXIT_SCAN_LINES = 12
172
+
125
173
  # The default per-kind page cadence for the account-level kinds: one
126
174
  # operator page per kind per six hours through the escalation ledger.
127
175
  PAGE_WINDOW_SECONDS = 6 * 3600
@@ -161,6 +209,34 @@ _LOGIN_EXPIRED_RE = re.compile(
161
209
  r"(?:Your )?(?:session|login|token) (?:has )?expired\b)",
162
210
  re.IGNORECASE,
163
211
  )
212
+ # The 401 wordings Anthropic documents (issue #140; design §1.4: the Claude
213
+ # Code *Errors* page and the Platform *Authentication* page): `API Error:
214
+ # 401 …`, `Invalid authentication credentials`, `Invalid API key`, `OAuth
215
+ # token has expired`, `OAuth token revoked`. Anchored like the banners
216
+ # above (an optional error glyph is the only thing allowed before the
217
+ # wording), and read on the de-decorated line: Claude Code draws the API
218
+ # error on a `⎿` row of its own under the turn that failed, so
219
+ # `_banner_line` admits THAT row for this one family -- a `●` turn or tool
220
+ # call above it in the same paragraph still makes it a worker's output.
221
+ _AUTH_REJECTED_RE = re.compile(
222
+ r"^(?:[✗✘⚠]\s*)?(?:API Error:\s*401\b|Invalid authentication credentials\b|"
223
+ r"Invalid API key\b|OAuth token (?:has )?expired\b|"
224
+ r"OAuth token (?:has been |was )?revoked\b)",
225
+ re.IGNORECASE,
226
+ )
227
+ # The specific wordings, tested on an auth line AFTER `_AUTH_REJECTED_RE`
228
+ # admitted it, most specific first; a line none of them names is the bare
229
+ # 401 (`api_error_401`), which is also what `API Error: 401 Invalid
230
+ # authentication credentials` reads as -- the credentials sentence is the
231
+ # 401's own message.
232
+ _AUTH_MATCHED_RES = (
233
+ (MATCHED_INVALID_API_KEY, re.compile(r"Invalid API key\b", re.IGNORECASE)),
234
+ (MATCHED_OAUTH_EXPIRED, re.compile(r"OAuth token (?:has )?expired\b", re.IGNORECASE)),
235
+ (MATCHED_OAUTH_REVOKED, re.compile(r"OAuth token (?:has been |was )?revoked\b", re.IGNORECASE)),
236
+ )
237
+ # A `⎿` tool-OUTPUT row specifically (the `●` turn row is the other half of
238
+ # `_OUTPUT_ROW_RE`): the row Claude Code draws its own API error on.
239
+ _TOOL_OUTPUT_ROW_RE = re.compile(r"^\s*⎿")
164
240
  _USAGE_LIMIT_RE = re.compile(
165
241
  r"^(?:(?:You've|You have) hit your (?:(?:weekly|daily|5-hour|session|usage) )?limit\b|"
166
242
  r"(?:Weekly|Daily|Session|Usage) limit reached\b|Usage limit\b|"
@@ -178,6 +254,11 @@ _OUT_OF_CREDITS_RE = re.compile(
178
254
  # (`⎿ ...`): a worker's output, which Claude Code never uses to draw a
179
255
  # notice of its own. Tested on the ANSI-stripped line, decoration intact.
180
256
  _OUTPUT_ROW_RE = re.compile(r"^\s*[●⎿]")
257
+ # The row Claude Code draws for a SUBMITTED prompt (`> implement issue
258
+ # #7`) or the bare input prompt at column 0 (`❯`): the boundary between
259
+ # one worker turn and the next, which is what releases the lines below
260
+ # it from the `●` above (issue #140, PR #141 review round 2).
261
+ _USER_PROMPT_ROW_RE = re.compile(r"^[>❯](?:\s|$)")
181
262
  # The dialog's path line, as the trust gate draws it: the path and nothing
182
263
  # else on the line.
183
264
  _PATH_LINE_RE = re.compile(r"^/[^\s'\"]+$")
@@ -273,14 +354,31 @@ def _is_banner_chrome(text: str) -> bool:
273
354
  return text in ("❯", ">") or _is_chrome(text)
274
355
 
275
356
 
357
+ def auth_matched(text: str) -> "str | None":
358
+ """Which documented 401 wording this (plain) line is, as the
359
+ `data.matched` token, or None when it is not an auth-rejected line at
360
+ all. The login-expired banner is NOT read here (its own regex and kind
361
+ already exist); `classify` stamps that finding `login_expired`."""
362
+ if not _AUTH_REJECTED_RE.search(text):
363
+ return None
364
+ for token, pattern in _AUTH_MATCHED_RES:
365
+ if pattern.search(text):
366
+ return token
367
+ return MATCHED_API_ERROR_401
368
+
369
+
276
370
  def _banner_line(lines: "list[str]") -> "tuple[int, str] | None":
277
371
  """The one line an account-level banner could be, as (index into
278
372
  `lines`, plain text), or None. Bottom-up: chrome and the bare input
279
373
  prompt are skipped, then the contiguous paragraph above them is read
280
374
  up to the blank line or chrome that bounds it. A `●`/`⎿` tool row
281
375
  anywhere in that paragraph makes it a worker's output (the row and its
282
- indented continuation lines), never a banner; otherwise the
283
- paragraph's last BANNER_LINES lines are the candidates."""
376
+ indented continuation lines), never a banner -- with ONE exception: a
377
+ `⎿` row that IS a documented 401 wording is how Claude Code draws its
378
+ own API error (issue #140), so that row is admitted as a candidate,
379
+ and a `●` turn above it in the same paragraph still voids the whole
380
+ paragraph. Otherwise the paragraph's last BANNER_LINES lines are the
381
+ candidates."""
284
382
  index = len(lines) - 1
285
383
  while index >= 0 and _is_banner_chrome(_plain(lines[index])):
286
384
  index -= 1
@@ -290,12 +388,16 @@ def _banner_line(lines: "list[str]") -> "tuple[int, str] | None":
290
388
  text = _plain(raw)
291
389
  if _is_banner_chrome(text):
292
390
  break
293
- if _OUTPUT_ROW_RE.match(raw):
391
+ if _OUTPUT_ROW_RE.match(raw) and not (
392
+ _TOOL_OUTPUT_ROW_RE.match(raw) and _AUTH_REJECTED_RE.search(text)
393
+ ):
294
394
  return None
295
395
  paragraph.append((index, text))
296
396
  index -= 1
297
397
  for index, text in paragraph[:BANNER_LINES]:
298
- for pattern in (_LOGIN_EXPIRED_RE, _USAGE_LIMIT_RE, _OUT_OF_CREDITS_RE):
398
+ for pattern in (
399
+ _LOGIN_EXPIRED_RE, _AUTH_REJECTED_RE, _USAGE_LIMIT_RE, _OUT_OF_CREDITS_RE,
400
+ ):
299
401
  if pattern.search(text):
300
402
  return index, text
301
403
  return None
@@ -323,7 +425,12 @@ class PromptFinding:
323
425
  it, scrubbed, for the DEBUG log and nothing else. `pane_hash` fingerprints
324
426
  the dialog so the loop can tell "the same pane, one poll later" from "a
325
427
  new prompt": the unknown-dialog ladder and the answered/unanswered
326
- re-capture both hang off it."""
428
+ re-capture both hang off it.
429
+
430
+ `matched` and `source` are the `auth.rejected` event's vocabulary
431
+ (issue #140), set on the two AUTH_KINDS only: which documented wording
432
+ was seen (`AUTH_MATCHED`) and whether it was read off a live pane or
433
+ the last lines of an exited one (`SOURCE_PANE` / `SOURCE_EXIT`)."""
327
434
 
328
435
  kind: str
329
436
  signature: str
@@ -331,6 +438,8 @@ class PromptFinding:
331
438
  options: "tuple[PromptOption, ...]"
332
439
  excerpt: "tuple[str, ...]"
333
440
  pane_hash: str
441
+ matched: "str | None" = None
442
+ source: str = SOURCE_PANE
334
443
 
335
444
  @property
336
445
  def selected(self) -> "PromptOption | None":
@@ -425,6 +534,96 @@ def _excerpt(lines: "list[str]", index: int) -> "tuple[str, ...]":
425
534
  return tuple(scrub(strip_ansi(ln).rstrip()) for ln in lines[lo:hi])
426
535
 
427
536
 
537
+ def _matched_for(kind: str, text: str) -> "str | None":
538
+ """The `matched` token of a banner finding: the login-expired banner is
539
+ reported as the same signal (`login_expired`), an auth line as the
540
+ wording it carries, every other kind as None."""
541
+ if kind == KIND_LOGIN_EXPIRED:
542
+ return MATCHED_LOGIN_EXPIRED
543
+ if kind == KIND_AUTH_REJECTED:
544
+ return auth_matched(text)
545
+ return None
546
+
547
+
548
+ def classify_exit(pane: str) -> "PromptFinding | None":
549
+ """The first-turn death check's half of the classifier (issue #140):
550
+ what a pane whose `claude` has already EXITED back to the shell says
551
+ about why, or None for the ordinary "died, not auth".
552
+
553
+ Only ever called on a pane the loop has established is a shell (the
554
+ pane's current command is one of `alissa.SHELL_COMMANDS`, inside the
555
+ first stale window of the spawn, no linked PR), so the banner rule --
556
+ the wording must be the LAST thing on screen -- does not apply: the
557
+ shell prompt is the last thing on screen, and the error sits a few
558
+ lines above it. The last EXIT_SCAN_LINES non-blank lines are read
559
+ bottom-up for a documented 401 wording or the login-expired banner; a
560
+ `●` turn row is skipped, and so is EVERY line that belongs to a turn
561
+ (`_under_a_turn`): every line below a `●` row until the next submitted
562
+ prompt row (`>`), paragraph breaks included -- Claude Code renders one
563
+ assistant message as blank-separated paragraphs and code blocks, and
564
+ only the first line carries the `●`. So the worker's own prose
565
+ wrapping onto the wording, a quoted error in a paragraph or code block
566
+ of its own, a README quoted under a tool call, a `grep` for it, are
567
+ all the worker's -- never Claude Code's error, which is drawn under
568
+ the `>` prompt row (`⎿ API Error: 401 …`) or, on a start-up refusal,
569
+ before any turn exists. The finding is an `auth_rejected` with
570
+ `source: "exit"` and the wording under `matched` -- the same kind the
571
+ pane detector raises, so the policy (page once, hold spawns) is the
572
+ same row."""
573
+ if not pane or not pane.strip():
574
+ return None
575
+ lines = pane.splitlines()
576
+ indices = [i for i, ln in enumerate(lines) if _plain(ln)][-EXIT_SCAN_LINES:]
577
+ for index in reversed(indices):
578
+ raw = strip_ansi(lines[index])
579
+ if _OUTPUT_ROW_RE.match(raw) and not _TOOL_OUTPUT_ROW_RE.match(raw):
580
+ continue
581
+ text = _plain(raw)
582
+ matched = (
583
+ MATCHED_LOGIN_EXPIRED if _LOGIN_EXPIRED_RE.search(text)
584
+ else auth_matched(text)
585
+ )
586
+ if matched is None:
587
+ continue
588
+ if _under_a_turn(lines, index):
589
+ # Any line under a `●` turn or tool call, up to the next `>`
590
+ # prompt row, is that turn's: a `⎿` row is its output (a README
591
+ # quoting the wording, a `grep` for it), a plain row is its own
592
+ # prose -- wrapped onto the wording, or quoting it in a later
593
+ # paragraph or code block. Claude Code's error is never drawn
594
+ # under a `●` without a `>` between them.
595
+ continue
596
+ return PromptFinding(
597
+ kind=KIND_AUTH_REJECTED,
598
+ signature=scrub(text),
599
+ target=None,
600
+ options=(),
601
+ excerpt=_excerpt(lines, index),
602
+ pane_hash=pane_hash(pane),
603
+ matched=matched,
604
+ source=SOURCE_EXIT,
605
+ )
606
+ return None
607
+
608
+
609
+ def _under_a_turn(lines: "list[str]", index: int) -> bool:
610
+ """Whether `lines[index]` belongs to a worker's turn: a `●` row sits
611
+ above it, anywhere in the capture, with no submitted-prompt row
612
+ (`_USER_PROMPT_ROW_RE`, the `>` Claude Code draws for the prompt that
613
+ opens the NEXT turn) between them. Blank lines and chrome do NOT end
614
+ a turn (PR #141 review round 2): one assistant message is several
615
+ blank-separated paragraphs and code blocks, `●` on its first line
616
+ only, so the paragraph is no boundary -- only the next prompt row is.
617
+ A `⎿` row above is the turn's output and bounds nothing either."""
618
+ for above in range(index - 1, -1, -1):
619
+ raw = strip_ansi(lines[above])
620
+ if _USER_PROMPT_ROW_RE.match(raw):
621
+ return False
622
+ if _OUTPUT_ROW_RE.match(raw) and not _TOOL_OUTPUT_ROW_RE.match(raw):
623
+ return True
624
+ return False
625
+
626
+
428
627
  def classify(pane: str) -> "PromptFinding | None":
429
628
  """What this pane is parked on, or None for a pane that is WORKING, idle
430
629
  at the input prompt, empty, or unreadable.
@@ -455,6 +654,7 @@ def classify(pane: str) -> "PromptFinding | None":
455
654
  index, text = banner
456
655
  for kind, pattern in (
457
656
  (KIND_LOGIN_EXPIRED, _LOGIN_EXPIRED_RE),
657
+ (KIND_AUTH_REJECTED, _AUTH_REJECTED_RE),
458
658
  (KIND_USAGE_LIMIT, _USAGE_LIMIT_RE),
459
659
  (KIND_OUT_OF_CREDITS, _OUT_OF_CREDITS_RE),
460
660
  ):
@@ -466,6 +666,7 @@ def classify(pane: str) -> "PromptFinding | None":
466
666
  options=(),
467
667
  excerpt=_excerpt(lines, index),
468
668
  pane_hash=digest,
669
+ matched=_matched_for(kind, text),
469
670
  )
470
671
 
471
672
  parked = _tail_is_dialog(lines)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.29
3
+ Version: 0.8.30
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera