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.
- {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
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/alissa.py +37 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/loop.py +154 -5
- {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
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/prompts.py +207 -6
- alissa_tools_github_devloop-0.8.30/src/main/alissa/tools/github/devloop/version +1 -0
- {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
- alissa_tools_github_devloop-0.8.29/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
- {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
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/config.py +0 -0
- {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
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.29 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
|
@@ -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,
|
|
2497
|
-
sent. Folded into the
|
|
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
|
-
|
|
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__(
|
|
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((
|
|
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
|
|
283
|
-
|
|
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 (
|
|
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)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.30
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.29
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|