alissa-tools-github-devloop 0.8.25__tar.gz → 0.8.26__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.25/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.26}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/alissa.py +147 -0
  3. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/config.py +114 -0
  4. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/loop.py +697 -12
  5. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/loop_events.py +37 -0
  6. alissa_tools_github_devloop-0.8.26/src/main/alissa/tools/github/devloop/prompts.py +761 -0
  7. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/state.py +146 -0
  8. alissa_tools_github_devloop-0.8.26/src/main/alissa/tools/github/devloop/version +1 -0
  9. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/webui/page.py +59 -0
  10. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/webui/server.py +108 -1
  11. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/webui/sources.py +53 -1
  12. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  13. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
  14. alissa_tools_github_devloop-0.8.25/src/main/alissa/tools/github/devloop/version +0 -1
  15. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/LICENSE +0 -0
  16. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/MANIFEST.in +0 -0
  17. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/NOTICE +0 -0
  18. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/README.md +0 -0
  19. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/requirements.txt +0 -0
  20. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/setup.cfg +0 -0
  21. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/setup.py +0 -0
  22. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  23. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  24. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  25. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  26. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
  27. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  28. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  29. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  30. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  31. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/version.py +0 -0
  32. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  33. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  34. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  35. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  36. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  37. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  38. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/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.25
3
+ Version: 0.8.26
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -8,12 +8,93 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import logging
11
+ import os
12
+ import re
11
13
  from pathlib import Path
12
14
 
13
15
  from .proc import CommandError, run
16
+ from .prompts import ALLOWED_KEYS
14
17
 
15
18
  log = logging.getLogger(__name__)
16
19
 
20
+ # The prefix `alissa tmux` gives every session it manages: the roster's
21
+ # `name` is the MANAGED name (`develop-…`, what `alissa tmux kill` takes) and
22
+ # the real tmux session is that name under this prefix (`ali-develop-…`).
23
+ # The raw-tmux surface below targets the REAL session, so a managed name is
24
+ # prefixed and a name already carrying the prefix is used as is.
25
+ MANAGED_PREFIX = "ali-"
26
+
27
+ # The console's session-name rule (webui.server._SAFE_SESSION), stated once
28
+ # where the raw tmux argv is built: starts with a letter/digit/dot/underscore,
29
+ # carries only those and `-`, bounded length. Validating here makes the argv
30
+ # impossible to weaponise -- a `-flag`-shaped name or one with a space,
31
+ # `;` or `/` never reaches tmux -- and the console re-checks the same rule.
32
+ SAFE_SESSION = re.compile(r"\A[A-Za-z0-9._][A-Za-z0-9._-]{0,199}\Z")
33
+
34
+
35
+ def tmux_socket() -> str:
36
+ """The path of the tmux server socket the worker sessions live on:
37
+ `$TMUX_TMPDIR/tmux-<uid>/default` (tmux's own default). Spelled out
38
+ rather than left to tmux because the daemon may run without `TMUX` in
39
+ its environment and must still reach the SAME server `alissa tmux new`
40
+ created for the workers -- same container, same user, same socket."""
41
+ base = os.environ.get("TMUX_TMPDIR", "").strip() or "/tmp"
42
+ return os.path.join(base, f"tmux-{os.getuid()}", "default")
43
+
44
+
45
+ def tmux_target(name: str) -> str:
46
+ """The `-t` target for a managed session: the real tmux name, prefixed
47
+ with `=` so tmux matches it EXACTLY (its default is prefix matching, and
48
+ `develop-acme-widgets-i7-a1` is a prefix of `…-a10`) and suffixed with
49
+ `:` so the target is the session's CURRENT WINDOW (and its active pane)
50
+ -- a bare `=name` is a session target, which `capture-pane` and
51
+ `send-keys` refuse with "can't find pane" (tmux 3.3a)."""
52
+ real = name if name.startswith(MANAGED_PREFIX) else MANAGED_PREFIX + name
53
+ return "=" + real + ":"
54
+
55
+
56
+ def check_session_name(name: "str | None") -> str:
57
+ """`name` when it passes SAFE_SESSION, else ValueError. Every raw tmux
58
+ argv builder calls this first."""
59
+ if not name or not SAFE_SESSION.match(name):
60
+ raise ValueError(f"invalid session name {name!r}")
61
+ return name
62
+
63
+
64
+ def capture_pane_argv(name: str, lines: int) -> "list[str]":
65
+ """`tmux -S <socket> capture-pane -p -t =<real> -S -<lines>`: the last
66
+ `lines` lines of the session's active pane, printed to stdout, with
67
+ escape sequences stripped (`-p` without `-e`)."""
68
+ check_session_name(name)
69
+ return [
70
+ "tmux", "-S", tmux_socket(), "capture-pane", "-p",
71
+ "-t", tmux_target(name), "-S", f"-{max(1, int(lines))}",
72
+ ]
73
+
74
+
75
+ def pane_path_argv(name: str) -> "list[str]":
76
+ check_session_name(name)
77
+ return [
78
+ "tmux", "-S", tmux_socket(), "display-message", "-p",
79
+ "-t", tmux_target(name), "#{pane_current_path}",
80
+ ]
81
+
82
+
83
+ def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
84
+ """`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
85
+ from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
86
+ is a ValueError before any process is spawned: the responder presses a
87
+ dialog's buttons and can never type into a worker."""
88
+ check_session_name(name)
89
+ if not keys:
90
+ raise ValueError("send_keys needs at least one key")
91
+ bad = [k for k in keys if k not in ALLOWED_KEYS]
92
+ if bad:
93
+ raise ValueError(
94
+ f"key(s) not in the allowlist {sorted(ALLOWED_KEYS)}: {bad!r}"
95
+ )
96
+ return ["tmux", "-S", tmux_socket(), "send-keys", "-t", tmux_target(name), *keys]
97
+
17
98
 
18
99
  class Alissa:
19
100
  def enqueue_developer(
@@ -149,6 +230,72 @@ class Alissa:
149
230
  `tmux kill`); the reaper treats kills as best-effort."""
150
231
  run(["alissa", "tmux", "kill", name], timeout=60)
151
232
 
233
+ def capture_pane(self, name: str, lines: int = 40) -> str:
234
+ """The last `lines` of ONE managed session's pane over raw tmux
235
+ (`capture-pane -p`), or "" when tmux cannot answer (no such session,
236
+ the socket gone, a timeout).
237
+
238
+ The responder's evidence seam (issue #127). Like `tail_session` it
239
+ returns UNTRUSTED, possibly sensitive terminal content: the caller
240
+ classifies it and lets only the classification -- plus a scrubbed
241
+ signature line -- reach an operator-facing level. A READ, so it runs
242
+ under dry-run too: a dry pass has to classify what a live one would.
243
+ Raw tmux rather than the `alissa` CLI because the CLI's `tail` is a
244
+ convenience without `send-keys` beside it, and the answer has to land
245
+ on the very pane the capture read."""
246
+ try:
247
+ argv = capture_pane_argv(name, lines)
248
+ except ValueError as exc:
249
+ log.warning("capture_pane refused: %s", exc)
250
+ return ""
251
+ try:
252
+ out = run(argv, timeout=15)
253
+ except CommandError as exc:
254
+ log.debug("could not capture the pane of %s (%s)", name, exc)
255
+ return ""
256
+ return out.rstrip("\n")
257
+
258
+ def pane_path(self, name: str) -> str:
259
+ """The session's pane current path (`#{pane_current_path}`), or ""
260
+ when tmux cannot answer. One leg of the lane's worktree resolution:
261
+ a worker that `cd`ed into its `TASK-*` worktree names it here."""
262
+ try:
263
+ argv = pane_path_argv(name)
264
+ except ValueError as exc:
265
+ log.warning("pane_path refused: %s", exc)
266
+ return ""
267
+ try:
268
+ return run(argv, timeout=15).strip()
269
+ except CommandError as exc:
270
+ log.debug("could not read the pane path of %s (%s)", name, exc)
271
+ return ""
272
+
273
+ def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
274
+ """Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
275
+ -- on ONE managed session's pane. True when tmux accepted them.
276
+
277
+ The ONLY key-pressing surface in the fleet, and deliberately narrow:
278
+ an allowlisted enum, a validated session name, an exact-match target,
279
+ one `send-keys` per call. A key outside the allowlist is refused
280
+ before any process is spawned and logged at WARNING (it is a
281
+ programming error, never a pane's doing -- keys come from the
282
+ policy table, not from the screen). Dry-run logs the intent and
283
+ sends nothing."""
284
+ try:
285
+ argv = send_keys_argv(name, tuple(keys))
286
+ except ValueError as exc:
287
+ log.warning("send_keys refused for %s: %s", name, exc)
288
+ return False
289
+ if dry_run:
290
+ log.info("[dry-run] would send keys %s to %s", " ".join(keys), name)
291
+ return False
292
+ try:
293
+ run(argv, timeout=15)
294
+ except CommandError as exc:
295
+ log.warning("could not send keys %s to %s (%s)", " ".join(keys), name, exc)
296
+ return False
297
+ return True
298
+
152
299
  def worker_running(self) -> bool:
153
300
  """The queue only drains while `alissa worker` reconciles it."""
154
301
  try:
@@ -108,6 +108,19 @@ ENV_LOOP_EVENTS = "ALISSA_DEV_LOOP_EVENTS_ENABLED"
108
108
  # non-blank-wins / blank-falls-through rule, same config error on a typo.
109
109
  ENV_FLEET_VITALS = "ALISSA_DEV_FLEET_VITALS_ENABLED"
110
110
 
111
+ # The prompt responder's master switch (issue #127): `on` answers, `observe`
112
+ # classifies and narrates but sends nothing, `off` never captures a pane.
113
+ PROMPT_ON = "on"
114
+ PROMPT_OBSERVE = "observe"
115
+ PROMPT_OFF = "off"
116
+ _PROMPT_MODES = frozenset((PROMPT_ON, PROMPT_OBSERVE, PROMPT_OFF))
117
+
118
+ # Where lane W1's hooks leave the "this session is waiting for input" marker
119
+ # (`docker/claude/hooks/note-waiting.py`): `${ALISSA_WAITING_DIR}/<tmux
120
+ # session>.json`. The daemon reads the same directory, so the two halves
121
+ # agree by construction on a default deployment.
122
+ DEFAULT_WAITING_DIR = "/workspace/.waiting"
123
+
111
124
  _ENV_BOOL_TRUE = frozenset(("1", "true"))
112
125
  _ENV_BOOL_FALSE = frozenset(("0", "false"))
113
126
 
@@ -181,6 +194,11 @@ CONFIG_KEYS = (
181
194
  "maintain_enabled",
182
195
  "resume_enabled",
183
196
  "wedge_minutes",
197
+ "prompt_responder",
198
+ "prompt_quiet_seconds",
199
+ "prompt_kill_minutes",
200
+ "prompt_max_answers",
201
+ "waiting_dir",
184
202
  "rerequest_enabled",
185
203
  "rerequest_grace_minutes",
186
204
  "orphan_sweep_enabled",
@@ -514,6 +532,39 @@ class Config:
514
532
  # never this field.
515
533
  wedge_minutes: int = 0
516
534
 
535
+ # The prompt responder (issue #127): every poll, for every ALIVE worker
536
+ # session this daemon owns, a waiting marker from lane W1's hook OR a
537
+ # session quiet for `prompt_quiet_seconds` triggers a pane capture, the
538
+ # classifier and the policy table (`prompts.py`), and the answer lands
539
+ # in seconds -- no SSH, no LLM in the loop. `on` (the default) answers;
540
+ # `observe` classifies and narrates but sends no key, kills nothing and
541
+ # pages nobody; `off` never captures a pane. Every side effect honours
542
+ # dry_run (a dry pass classifies and logs, sends nothing).
543
+ prompt_responder: str = PROMPT_ON
544
+
545
+ # How long a worker session must have been quiet (`lastActivity` in the
546
+ # roster) before its pane is read WITHOUT a waiting marker. The marker
547
+ # is the fast path (seconds); this is the net under it, for a hook that
548
+ # did not fire. Floored at 30 so a session between two tool calls is not
549
+ # read as parked.
550
+ prompt_quiet_seconds: int = 90
551
+
552
+ # The unknown-dialog ladder's last rung: a dialog no signature knows is
553
+ # waited out one poll, dismissed with Escape on its second sighting, and
554
+ # after this many minutes still on screen the session is killed so the
555
+ # ordinary retry/resume edge takes over. Floored at 1.
556
+ prompt_kill_minutes: int = 10
557
+
558
+ # The per-session answer cap: after this many keystroke answers the
559
+ # session is killed-and-retried -- a worker that keeps producing prompts
560
+ # is diverging, and the narration says so. Floored at 1.
561
+ prompt_max_answers: int = 5
562
+
563
+ # Where the waiting markers are (see DEFAULT_WAITING_DIR). The hooks
564
+ # read ALISSA_WAITING_DIR at run time; the container's config generator
565
+ # writes the same value here so the daemon looks where the hooks wrote.
566
+ waiting_dir: str = DEFAULT_WAITING_DIR
567
+
517
568
  # The wedge self-heal (issue #61): the fix edge's crash-recovery net under
518
569
  # FIX_DIRECTIVE's terminal step. A fix session that posted its triage
519
570
  # replies and PUSHED, then died before re-requesting review, leaves the PR
@@ -735,6 +786,23 @@ class Config:
735
786
  return 0
736
787
  return max(self.wedge_minutes, self.stale_minutes) * 60
737
788
 
789
+ @property
790
+ def prompt_kill_seconds(self) -> int:
791
+ """The unknown-dialog kill rung in seconds, floored at one minute
792
+ here as well as in `build()` so a Config assembled without it can
793
+ never kill a session on its first sighting."""
794
+ return max(1, self.prompt_kill_minutes) * 60
795
+
796
+ @property
797
+ def prompt_quiet_floor(self) -> int:
798
+ """`prompt_quiet_seconds` with the 30 s floor applied again (the
799
+ `wedge_seconds` contract: `build()` clamps, and so does the read)."""
800
+ return max(30, self.prompt_quiet_seconds)
801
+
802
+ @property
803
+ def prompt_answer_cap(self) -> int:
804
+ return max(1, self.prompt_max_answers)
805
+
738
806
  @property
739
807
  def janitor_grace_seconds(self) -> int:
740
808
  """How long an anchor must have been terminal before its workspace may
@@ -1014,6 +1082,47 @@ class Config:
1014
1082
  )
1015
1083
  wedge = stale
1016
1084
 
1085
+ # The prompt responder's knobs (issue #127). The mode is an enum
1086
+ # validated by NAME like foreign_session_policy (a typo must not read
1087
+ # as "off"); the three numbers have FLOORS rather than 0-sentinels --
1088
+ # none of them has a meaningful "off" value, and each floor is the
1089
+ # smallest value that cannot answer a session that is merely between
1090
+ # two tool calls -- and are clamped up with a warning, the wedge
1091
+ # window's contract, because none of them is worth failing a boot.
1092
+ prompt_mode = str(raw.get("prompt_responder", cls.prompt_responder)).strip()
1093
+ if prompt_mode not in _PROMPT_MODES:
1094
+ raise ValueError(
1095
+ f"prompt_responder must be one of {sorted(_PROMPT_MODES)}, "
1096
+ f"got {prompt_mode!r}"
1097
+ )
1098
+ prompt_quiet = int(raw.get("prompt_quiet_seconds", cls.prompt_quiet_seconds))
1099
+ if prompt_quiet < 30:
1100
+ log.warning(
1101
+ "prompt_quiet_seconds=%d is below the 30 s floor — a shorter "
1102
+ "window would read a session between two tool calls as "
1103
+ "parked; clamping up to 30", prompt_quiet,
1104
+ )
1105
+ prompt_quiet = 30
1106
+ prompt_kill = int(raw.get("prompt_kill_minutes", cls.prompt_kill_minutes))
1107
+ if prompt_kill < 1:
1108
+ log.warning(
1109
+ "prompt_kill_minutes=%d is below the 1 min floor — the "
1110
+ "unknown-dialog ladder needs at least one poll between the "
1111
+ "dismissal and the kill; clamping up to 1", prompt_kill,
1112
+ )
1113
+ prompt_kill = 1
1114
+ prompt_answers = int(raw.get("prompt_max_answers", cls.prompt_max_answers))
1115
+ if prompt_answers < 1:
1116
+ log.warning(
1117
+ "prompt_max_answers=%d is below the floor of 1 — a cap of 0 "
1118
+ "would kill every session on its first prompt; clamping up "
1119
+ "to 1", prompt_answers,
1120
+ )
1121
+ prompt_answers = 1
1122
+ waiting_dir = str(raw.get("waiting_dir", cls.waiting_dir)).strip()
1123
+ if not waiting_dir:
1124
+ raise ValueError("waiting_dir must be a non-empty path")
1125
+
1017
1126
  # The janitor's grace, the reaper's sentinel one rung longer (0 = use
1018
1127
  # reap_grace_minutes). Negative is the only invalid value, and it is
1019
1128
  # the dangerous one: it would license reclaiming a workspace the
@@ -1110,6 +1219,11 @@ class Config:
1110
1219
  raw.get("resume_enabled", cls.resume_enabled)
1111
1220
  ),
1112
1221
  wedge_minutes=wedge,
1222
+ prompt_responder=prompt_mode,
1223
+ prompt_quiet_seconds=prompt_quiet,
1224
+ prompt_kill_minutes=prompt_kill,
1225
+ prompt_max_answers=prompt_answers,
1226
+ waiting_dir=waiting_dir,
1113
1227
  rerequest_enabled=bool(
1114
1228
  raw.get("rerequest_enabled", cls.rerequest_enabled)
1115
1229
  ),