alissa-tools-github-devloop 0.8.28__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.28/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.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/alissa.py +37 -0
  3. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/config.py +113 -3
  4. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/loop.py +218 -10
  5. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/loop_events.py +116 -3
  6. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/prompts.py +207 -6
  7. alissa_tools_github_devloop-0.8.30/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  9. alissa_tools_github_devloop-0.8.28/src/main/alissa/tools/github/devloop/version +0 -1
  10. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/LICENSE +0 -0
  11. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/MANIFEST.in +0 -0
  12. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/NOTICE +0 -0
  13. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/README.md +0 -0
  14. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/requirements.txt +0 -0
  15. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/setup.cfg +0 -0
  16. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/setup.py +0 -0
  17. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  18. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  19. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  20. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  21. {alissa_tools_github_devloop-0.8.28 → 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.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  23. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  24. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  25. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  26. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/state.py +0 -0
  27. {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/version.py +0 -0
  28. {alissa_tools_github_devloop-0.8.28 → 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.28 → 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.28 → 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.28 → 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.28 → 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.28 → 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.28 → 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.28 → 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.28 → 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.28 → 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.28 → 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.28
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.
@@ -26,6 +26,10 @@ through to the config rather than fail the daemon's boot on a mode of `''`.
26
26
  `fleet_vitals_enabled` through `ALISSA_DEV_FLEET_VITALS_ENABLED` likewise.
27
27
  `revert_label` rides `ALISSA_REVERT_LABEL` the same way (issue #131): a
28
28
  non-blank value wins over the CLI and the file, a blank one falls through.
29
+
30
+ `regenerate` (issue #138) is env-only: `ALISSA_DEV_REGENERATE` maps
31
+ repo-scoped globs to the command that regenerates them, and the daemon only
32
+ ever RENDERS that map into a worker's directive -- it never runs the command.
29
33
  """
30
34
 
31
35
  from __future__ import annotations
@@ -116,6 +120,94 @@ ENV_FLEET_VITALS = "ALISSA_DEV_FLEET_VITALS_ENABLED"
116
120
  # it (no `_DEV_` infix), because that is the name the operator runbook gives.
117
121
  ENV_REVERT_LABEL = "ALISSA_REVERT_LABEL"
118
122
 
123
+ # The regenerable-paths map (issue #138): `owner/repo:<glob>=<command>`
124
+ # entries, `|`-separated (the daemon-family list separator -- a command may
125
+ # itself carry `,`, `;` or spaces, so `|` is the ONLY separator here), default
126
+ # empty. Env-only, read straight from the environment like ENV_REVERT_LABEL:
127
+ # not a config key, not a build ARG, not written by the config generator. Each
128
+ # entry is validated at load and a malformed one is refused by NAME -- the
129
+ # variable's and the entry's -- because the failure mode of a silently
130
+ # dropped entry is a worker hand-merging a generated file a poll later. The
131
+ # command is rendered into the worker's directive and runs INSIDE the
132
+ # worker's worktree as part of the session; the daemon never executes it.
133
+ ENV_REGENERATE = "ALISSA_DEV_REGENERATE"
134
+
135
+ # `owner/repo` for a regenerate entry: exactly one `/`, no whitespace, and no
136
+ # `:` (the separator that ends it). Shape only, like the actor-id check.
137
+ _REGENERATE_REPO_RE = re.compile(r"^[^\s/:]+/[^\s/:]+\Z")
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class RegenerateRule:
142
+ """One `ALISSA_DEV_REGENERATE` entry: files under `glob` in `repo` are a
143
+ pure function of the code, regenerated by `command` (run in the worker's
144
+ worktree, never by the daemon). `repo` keeps the operator's casing;
145
+ matching casefolds, as `Config.watches` does."""
146
+
147
+ repo: str
148
+ glob: str
149
+ command: str
150
+
151
+
152
+ def parse_regenerate(raw: str) -> "tuple[RegenerateRule, ...]":
153
+ """Parse the ALISSA_DEV_REGENERATE value into rules, refusing malformed
154
+ entries by name.
155
+
156
+ The grammar is `owner/repo:<glob>=<command>` per entry, split on `|`
157
+ ONLY: the repo ends at the first `:`, the glob at the first `=`, and the
158
+ command is everything after it (so a command may carry `=` and `:`, and a
159
+ glob may carry `:` but not `=`). Whitespace around an entry and its three
160
+ parts is stripped; empty entries (a trailing `|`) are dropped, so a blank
161
+ or unset variable is the empty map -- today's behaviour exactly. An
162
+ entry with a missing part, a repo that is not `owner/repo`, or the same
163
+ repo+glob given twice with different commands is a ValueError naming
164
+ ENV_REGENERATE and the entry: a dropped entry would surface a poll later
165
+ as a hand-merged generated file, which is the very thing the map exists
166
+ to prevent. Exact duplicates collapse.
167
+ """
168
+ rules: "list[RegenerateRule]" = []
169
+ seen: "dict[tuple[str, str], RegenerateRule]" = {}
170
+ for part in (raw or "").split("|"):
171
+ entry = part.strip()
172
+ if not entry:
173
+ continue
174
+
175
+ def refuse(problem: str) -> ValueError:
176
+ return ValueError(
177
+ f"{ENV_REGENERATE} entry {entry!r} is malformed: {problem} "
178
+ f"(expected owner/repo:<glob>=<command>, entries separated "
179
+ f"by '|')"
180
+ )
181
+
182
+ repo, colon, rest = entry.partition(":")
183
+ if not colon:
184
+ raise refuse("missing ':' between the repo and the glob")
185
+ repo = repo.strip()
186
+ if not _REGENERATE_REPO_RE.match(repo):
187
+ raise refuse(f"repo {repo!r} is not of the form owner/repo")
188
+ glob, equals, command = rest.partition("=")
189
+ if not equals:
190
+ raise refuse("missing '=' between the glob and the command")
191
+ glob = glob.strip()
192
+ command = command.strip()
193
+ if not glob:
194
+ raise refuse("the glob is empty")
195
+ if not command:
196
+ raise refuse("the command is empty")
197
+ rule = RegenerateRule(repo=repo, glob=glob, command=command)
198
+ key = (repo.casefold(), glob)
199
+ prior = seen.get(key)
200
+ if prior is not None:
201
+ if prior.command != command:
202
+ raise refuse(
203
+ f"{repo}:{glob} is already mapped to {prior.command!r}"
204
+ )
205
+ continue
206
+ seen[key] = rule
207
+ rules.append(rule)
208
+ return tuple(rules)
209
+
210
+
119
211
  # The prompt responder's master switch (issue #127): `on` answers, `observe`
120
212
  # classifies and narrates but sends nothing, `off` never captures a pane.
121
213
  PROMPT_ON = "on"
@@ -744,6 +836,12 @@ class Config:
744
836
  # to act (at the incident's growth rate, ~2 days). 0 never warns.
745
837
  disk_warn_percent: int = 80
746
838
 
839
+ # The regenerable-paths map (issue #138), from ALISSA_DEV_REGENERATE only
840
+ # (no config key, no CLI flag). Empty by default, and empty means every
841
+ # worker directive renders byte-identical to a build without the map.
842
+ # Read through `regenerate_for(owner, repo)`, never executed here.
843
+ regenerate: tuple[RegenerateRule, ...] = ()
844
+
747
845
  dry_run: bool = False
748
846
 
749
847
  # Derived at build time: `repos` casefolded for matching. GitHub names are
@@ -875,6 +973,13 @@ class Config:
875
973
  and gets an answer that includes the enrolled repos."""
876
974
  return full_name.casefold() in self._repo_match
877
975
 
976
+ def regenerate_for(self, owner: str, repo: str) -> "tuple[RegenerateRule, ...]":
977
+ """The regenerate rules scoped to `owner/repo`, in the order the
978
+ operator wrote them; empty for a repo with no entry. Casefolded like
979
+ `watches`: GitHub names are case-insensitive."""
980
+ wanted = f"{owner}/{repo}".casefold()
981
+ return tuple(r for r in self.regenerate if r.repo.casefold() == wanted)
982
+
878
983
  def trusts_feed_owner(self, actor_id: "str | None") -> bool:
879
984
  """May a Body of Work owned by this actor act as a repo feed?
880
985
 
@@ -900,9 +1005,10 @@ class Config:
900
1005
  """Merge the layers and validate. `overrides` entries that are None mean
901
1006
  "not specified on the CLI" and fall through to the file / defaults.
902
1007
 
903
- `environ` is only read for `ALISSA_DEV_REPOS_SOURCE`, which rides one
904
- rail above the CLI (see the module docstring); it defaults to the real
905
- environment and is a parameter so a test never has to monkeypatch it."""
1008
+ `environ` is only read for the env rails (`ALISSA_DEV_REPOS_SOURCE`
1009
+ and its siblings, see the module docstring, plus the env-only
1010
+ `ALISSA_DEV_REGENERATE` map); it defaults to the real environment and
1011
+ is a parameter so a test never has to monkeypatch it."""
906
1012
  raw: dict[str, Any] = dict(file_data or {})
907
1013
 
908
1014
  if "workspace_root" in raw:
@@ -994,6 +1100,9 @@ class Config:
994
1100
  revert_label = env_revert_label
995
1101
  if not revert_label.strip():
996
1102
  raise ValueError("revert_label must not be blank")
1103
+ # The regenerable-paths map (issue #138): env-only, validated here so
1104
+ # a malformed entry fails the boot by name instead of a directive.
1105
+ regenerate = parse_regenerate(env_map.get(ENV_REGENERATE, ""))
997
1106
 
998
1107
  bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
999
1108
 
@@ -1284,6 +1393,7 @@ class Config:
1284
1393
  raw.get("cache_paths", cls.cache_paths), "cache_paths"
1285
1394
  ),
1286
1395
  disk_warn_percent=warn_percent,
1396
+ regenerate=regenerate,
1287
1397
  dry_run=bool(raw.get("dry_run", cls.dry_run)),
1288
1398
  )
1289
1399
 
@@ -458,13 +458,13 @@ from collections import Counter
458
458
  from dataclasses import dataclass, replace
459
459
  from enum import Enum
460
460
  from pathlib import Path
461
- from typing import Callable
461
+ from typing import Callable, Sequence
462
462
 
463
463
  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 (
@@ -486,6 +486,7 @@ from .config import (
486
486
  PROMPT_OFF,
487
487
  REPOS_BOWS,
488
488
  Config,
489
+ RegenerateRule,
489
490
  )
490
491
  from .ghclient import (
491
492
  FIRST_PARENT_MAX_PAGES,
@@ -771,6 +772,49 @@ _SHELL_RULE = (
771
772
  "change the command; do not retry it. "
772
773
  )
773
774
 
775
+ # The regenerate-on-conflict rule (issue #138), rendered into every worker
776
+ # directive that can end up merging main into its branch -- develop, resume,
777
+ # fix, fix-resume and maintain -- and ONLY for a repo with entries in
778
+ # ALISSA_DEV_REGENERATE: `render_regenerate_rule` returns "" otherwise, so a
779
+ # repo without entries (and a deployment without the variable) renders every
780
+ # directive byte-identical to before the rule existed. A generated file is a
781
+ # pure function of the code; a hand-merge of it usually works and is still
782
+ # the wrong operation (the repo's generated-drift CI job fails any hand-merge
783
+ # that does not match, at the cost of a review round), so the rule is: take
784
+ # either side, finish the merge, rerun the generator, commit. The command
785
+ # runs in the worker's worktree as part of the session -- the daemon never
786
+ # executes it, it only spells it here.
787
+ _REGENERATE_RULE_ONE = (
788
+ "If merging main conflicts inside `{glob}`, do not hand-merge it: "
789
+ "`git checkout --theirs -- <paths>` (or `--ours` — either side), finish "
790
+ "the merge, run `{command}`, and commit the regenerated files in the "
791
+ "same push; the repo's CI verifies them. "
792
+ )
793
+ _REGENERATE_RULE_MANY = (
794
+ "If merging main conflicts inside {paths}, do not hand-merge it: "
795
+ "`git checkout --theirs -- <paths>` (or `--ours` — either side), finish "
796
+ "the merge, run that path's regenerate command, and commit the "
797
+ "regenerated files in the same push; the repo's CI verifies them. "
798
+ )
799
+
800
+
801
+ def render_regenerate_rule(rules: "Sequence[RegenerateRule]") -> str:
802
+ """The rule for one repo's ALISSA_DEV_REGENERATE entries, or "" when it
803
+ has none. One entry renders the issue's own sentence with the glob and the
804
+ command substituted; several render one sentence naming each glob with
805
+ its command. Always ends in a space so it splices between two sentences
806
+ of a directive, and its placeholder renders to nothing when empty."""
807
+ if not rules:
808
+ return ""
809
+ if len(rules) == 1:
810
+ return _REGENERATE_RULE_ONE.format(
811
+ glob=rules[0].glob, command=rules[0].command
812
+ )
813
+ named = [f"`{r.glob}` (regenerate with `{r.command}`)" for r in rules]
814
+ paths = ", ".join(named[:-1]) + " or " + named[-1]
815
+ return _REGENERATE_RULE_MANY.format(paths=paths)
816
+
817
+
774
818
  DEV_DIRECTIVE = (
775
819
  "You are an IMPLEMENTER, not a reviewer. Implement GitHub issue "
776
820
  "{issue_url} (attempt {attempt} of {cap}) — the issue is your "
@@ -786,7 +830,8 @@ DEV_DIRECTIVE = (
786
830
  + _BRANCH_RULE
787
831
  + _SHELL_RULE
788
832
  + "Implement to the issue's acceptance criteria and VERIFY locally (run the "
789
- "repo's test/style/type scripts) before publishing. Open a DRAFT PR whose "
833
+ "repo's test/style/type scripts) before publishing. {regenerate_rule}"
834
+ "Open a DRAFT PR whose "
790
835
  "body contains `Closes #{number}` and BOTH task refs — the origin task "
791
836
  "AND your implementation task: the PR body is the canonical cross-actor "
792
837
  "record, and each actor's tasks live in its own partition — and attach "
@@ -845,7 +890,8 @@ RESUME_DIRECTIVE = (
845
890
  "are unchanged and still authoritative — and VERIFY locally (run the "
846
891
  "repo's test/style/type scripts) before publishing. NEVER force-push: "
847
892
  "history stays append-only, or the PR's review threads lose their "
848
- "anchors. Keep the PR body carrying `Closes #{number}` and BOTH task refs "
893
+ "anchors. {regenerate_rule}"
894
+ "Keep the PR body carrying `Closes #{number}` and BOTH task refs "
849
895
  "(the origin task AND your implementation task) — restoring them if the "
850
896
  "dead session did not. "
851
897
  + _SHELL_RULE
@@ -906,7 +952,7 @@ FIX_DIRECTIVE = (
906
952
  + "FIRST verify the triage of every prior review finding per CR8: exactly "
907
953
  "one reasoned `[triage:*]` reply on each finding's thread — post any that "
908
954
  "are missing. Then fix the pursued findings on the same branch and VERIFY "
909
- "locally (run the repo's test/style/type scripts). "
955
+ "locally (run the repo's test/style/type scripts). {regenerate_rule}"
910
956
  + _NEWER_ROUNDS
911
957
  + _FIX_DELIVERY_NOTE
912
958
  + " "
@@ -964,7 +1010,7 @@ FIX_RESUME_DIRECTIVE = (
964
1010
  "thread it already answered. Then fix the pursued findings, completing "
965
1011
  "the ORIGINAL acceptance criteria the issue set, and VERIFY locally (run "
966
1012
  "the repo's test/style/type scripts). NEVER force-push: history stays "
967
- "append-only, or the review threads lose their anchors. "
1013
+ "append-only, or the review threads lose their anchors. {regenerate_rule}"
968
1014
  + _NEWER_ROUNDS
969
1015
  + _FIX_DELIVERY_NOTE
970
1016
  + " "
@@ -1015,6 +1061,7 @@ MAINTAIN_DIRECTIVE = (
1015
1061
  "while history is append-only — resolving any conflicts so BOTH sides' "
1016
1062
  "intent is preserved, then run the repo's four scripts (unit tests, "
1017
1063
  "coverage, style, types) and get ALL of them green before publishing. "
1064
+ "{regenerate_rule}"
1018
1065
  + _REVIEW_SEAT
1019
1066
  + "The terminal sequence's ORDER is load-bearing: push the merge commit, "
1020
1067
  "THEN re-request review from {reviewers} — the push stales any approve, "
@@ -1221,6 +1268,10 @@ PROMPT_PAGE_COMMENT = (
1221
1268
  "not answering it, and it is HOLDING new worker spawns until the "
1222
1269
  "condition clears (live sessions keep running). Operator options: "
1223
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; "
1224
1275
  "`usage_limit` → wait for the reset or raise the plan; `out_of_credits` "
1225
1276
  "→ top up. The hold lifts on the first poll that no longer sees the "
1226
1277
  "notice and in any case after {hold_minutes} min on the same pane: the "
@@ -1528,6 +1579,11 @@ ESCALATION_PROMPT_PAGE = "prompt-page"
1528
1579
  # a slow container.
1529
1580
  PROMPT_SETTLE_SECONDS = 3.0
1530
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
+
1531
1587
 
1532
1588
  def prompt_event_kind(kind: str, verb: str, session: str, seq: int) -> str:
1533
1589
  """The ledger kind of ONE responder act: `prompt:<kind>:<verb>@<session>#<seq>`.
@@ -2435,6 +2491,11 @@ class Sweep:
2435
2491
  waiting: "tuple[dict, ...]" = ()
2436
2492
  hold: "str | None" = None
2437
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, ...]" = ()
2438
2499
 
2439
2500
  @property
2440
2501
  def total(self) -> int:
@@ -2446,12 +2507,14 @@ class Sweep:
2446
2507
  class PromptSweep:
2447
2508
  """What ONE prompt-responder pass found and did (issue #127): the
2448
2509
  sessions parked on a prompt (`{session, kind, since, action}`), the
2449
- spawn hold an account-level notice raised, and the keystroke answers
2450
- 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`."""
2451
2513
 
2452
2514
  waiting: "tuple[dict, ...]" = ()
2453
2515
  hold: "str | None" = None
2454
2516
  answered: int = 0
2517
+ auth_rejected: "tuple[dict, ...]" = ()
2455
2518
 
2456
2519
 
2457
2520
  @dataclass(frozen=True, slots=True)
@@ -3200,6 +3263,16 @@ class DevWatcher:
3200
3263
  # rebuilt only when the allowlist itself changes rather than per
3201
3264
  # session per sweep.
3202
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]" = {}
3203
3276
  # The janitor's two pieces of per-process state: when its expensive
3204
3277
  # half last ran (monotonic; None = never, so the first pass of a
3205
3278
  # process always sweeps) and the last per-hub breakdown it walked,
@@ -3680,6 +3753,7 @@ class DevWatcher:
3680
3753
  "none resolved — follow the alissa-pr-review skill's reviewer "
3681
3754
  "resolution"
3682
3755
  )
3756
+ regenerate_rule = self._regenerate_rule(issue.owner, issue.repo)
3683
3757
  if resume_pr is not None:
3684
3758
  directive = RESUME_DIRECTIVE.format(
3685
3759
  issue_url=issue.url,
@@ -3693,6 +3767,7 @@ class DevWatcher:
3693
3767
  head_ref=resume_pr.head_ref,
3694
3768
  worktree_dir=worktree_dir(resume_pr.head_ref),
3695
3769
  dead_session=dead_session,
3770
+ regenerate_rule=regenerate_rule,
3696
3771
  )
3697
3772
  else:
3698
3773
  directive = DEV_DIRECTIVE.format(
@@ -3703,6 +3778,7 @@ class DevWatcher:
3703
3778
  task_instruction=task_instruction,
3704
3779
  reviewers=reviewers,
3705
3780
  session=name,
3781
+ regenerate_rule=regenerate_rule,
3706
3782
  )
3707
3783
 
3708
3784
  # assign_self FIRST: the assignment is the server-side in-flight
@@ -6802,6 +6878,7 @@ class DevWatcher:
6802
6878
  reviewer=review.author,
6803
6879
  session=name,
6804
6880
  dead_session=dead_session,
6881
+ regenerate_rule=self._regenerate_rule(pr.owner, pr.repo),
6805
6882
  )
6806
6883
 
6807
6884
  # BEFORE the enqueue (issue #53), for the reason _spawn spells out:
@@ -7065,6 +7142,7 @@ class DevWatcher:
7065
7142
  worktree_dir=worktree_dir(pr.head_ref),
7066
7143
  reviewers=reviewers,
7067
7144
  session=name,
7145
+ regenerate_rule=self._regenerate_rule(pr.owner, pr.repo),
7068
7146
  )
7069
7147
 
7070
7148
  # BEFORE the enqueue (issue #53), for the reason _spawn spells out:
@@ -7685,6 +7763,13 @@ class DevWatcher:
7685
7763
  if self._append_activity(owner, repo, number, line, display_slug):
7686
7764
  self.state.record_activity(slug_key, number, kind)
7687
7765
 
7766
+ def _regenerate_rule(self, owner: str, repo: str) -> str:
7767
+ """The regenerate-on-conflict clause for this repo's directives
7768
+ (issue #138): "" -- and so a byte-identical directive -- unless
7769
+ ALISSA_DEV_REGENERATE names `owner/repo`. Shared by all five worker
7770
+ directives. Rendered only: the command is the worker's to run."""
7771
+ return render_regenerate_rule(self.config.regenerate_for(owner, repo))
7772
+
7688
7773
  def _ensure_hub(self, owner: str, repo: str) -> tuple[Path, str | None]:
7689
7774
  """Resolve a spawn's cwd (the hub ROOT), hub-ifying the repo first if
7690
7775
  configured. Shared by all three edges (issue, fix, and maintenance
@@ -8530,6 +8615,7 @@ class DevWatcher:
8530
8615
  waiting: "list[dict]" = []
8531
8616
  hold: "str | None" = None
8532
8617
  answered = 0
8618
+ rejected: "list[dict]" = []
8533
8619
  for entry in sessions:
8534
8620
  name = str(entry.get("name") or "")
8535
8621
  worker = parse_worker_session(name)
@@ -8537,6 +8623,20 @@ class DevWatcher:
8537
8623
  continue
8538
8624
  live.add(name)
8539
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
8540
8640
  marker = self._waiting_marker(tmux_name)
8541
8641
  quiet = self._entry_quiet_for(entry)
8542
8642
  if marker is None and (
@@ -8565,8 +8665,88 @@ class DevWatcher:
8565
8665
  answered += outcome["answered"]
8566
8666
  if outcome["hold"] and hold is None:
8567
8667
  hold = outcome["hold"]
8668
+ if outcome["auth"]:
8669
+ rejected.append(outcome["auth"])
8568
8670
  self.state.prune_prompt_sightings(live)
8569
- 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
8570
8750
 
8571
8751
  def _answer_prompt(
8572
8752
  self,
@@ -8711,10 +8891,16 @@ class DevWatcher:
8711
8891
  tail = " · killed; the ordinary retry edge respawns"
8712
8892
  elif action.sends_keys and not sent and not self.config.dry_run:
8713
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
+ )
8714
8900
  line = self._activity_line(
8715
8901
  name, f"{worker.edge} edge",
8716
8902
  f"{prefix}: {finding.kind} → {action.verb} ({action.reason})"
8717
- f"{f' · target {target_rel}' if target_rel else ''}"
8903
+ f"{f' · target {target_rel}' if target_rel else ''}{auth_tail}"
8718
8904
  f" · {since} s after it appeared{tail}",
8719
8905
  )
8720
8906
  self._append_activity(owner, repo_name, worker.number, line, f"{slug}#{worker.number}")
@@ -8728,6 +8914,25 @@ class DevWatcher:
8728
8914
  "prompt responder: %s (%s) is not on the allowlist — %s → %s "
8729
8915
  "recorded in the log only", name, worker.slug, finding.kind, action.verb,
8730
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
8731
8936
  return {
8732
8937
  "waiting": {
8733
8938
  "session": name, "kind": finding.kind,
@@ -8736,6 +8941,7 @@ class DevWatcher:
8736
8941
  },
8737
8942
  "answered": 1 if sent else 0,
8738
8943
  "hold": hold,
8944
+ "auth": auth,
8739
8945
  }
8740
8946
 
8741
8947
  def _page_prompt(
@@ -8926,6 +9132,7 @@ class DevWatcher:
8926
9132
  prompt = self._respond_to_prompts(sessions, sweep.names)
8927
9133
  sweep = replace(
8928
9134
  sweep, waiting=prompt.waiting, hold=prompt.hold, answered=prompt.answered,
9135
+ auth_rejected=prompt.auth_rejected,
8929
9136
  )
8930
9137
  if sweep.hold:
8931
9138
  log.warning(
@@ -9991,6 +10198,7 @@ class DevWatcher:
9991
10198
  self.loop_events.emit_pass(
9992
10199
  deferrals=deferrals,
9993
10200
  reaps=self._reap_records(sweep.names),
10201
+ auth_rejections=list(sweep.auth_rejected),
9994
10202
  now_ms=int(time.time() * 1000),
9995
10203
  )
9996
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.28
3
+ Version: 0.8.30
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera