alissa-tools-github-revloop 0.31.2__tar.gz → 0.31.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {alissa_tools_github_revloop-0.31.2/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.3}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/alissa.py +166 -0
  3. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/config.py +119 -0
  4. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/loop.py +823 -12
  5. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/loop_events.py +49 -0
  6. alissa_tools_github_revloop-0.31.3/src/main/alissa/tools/github/revloop/prompts.py +779 -0
  7. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/state.py +197 -3
  8. alissa_tools_github_revloop-0.31.3/src/main/alissa/tools/github/revloop/version +1 -0
  9. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/page.py +66 -0
  10. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/server.py +119 -3
  11. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/sources.py +85 -6
  12. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  13. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
  14. alissa_tools_github_revloop-0.31.2/src/main/alissa/tools/github/revloop/version +0 -1
  15. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/LICENSE +0 -0
  16. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/MANIFEST.in +0 -0
  17. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/NOTICE +0 -0
  18. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/README.md +0 -0
  19. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/requirements.txt +0 -0
  20. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/setup.cfg +0 -0
  21. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/setup.py +0 -0
  22. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  24. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  25. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  26. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  27. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  28. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  29. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  30. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  31. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/version.py +0 -0
  32. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  33. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  34. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  35. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  36. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  37. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.2
3
+ Version: 0.31.3
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -4,12 +4,15 @@ reviewer session (orchestration P1)."""
4
4
  from __future__ import annotations
5
5
 
6
6
  import logging
7
+ import os
7
8
  import re
9
+ import time
8
10
  from dataclasses import dataclass
9
11
  from datetime import datetime
10
12
  from pathlib import Path
11
13
 
12
14
  from .proc import CommandError, run, run_json
15
+ from .prompts import ALLOWED_KEYS
13
16
 
14
17
  log = logging.getLogger(__name__)
15
18
 
@@ -336,6 +339,83 @@ class Task:
336
339
  # kill on.
337
340
  REVIEW_SESSION_PREFIX = "review-"
338
341
 
342
+ # `alissa tmux new` names the REAL tmux session `ali-<name>`; the roster's
343
+ # `name` is the managed name without it. The raw tmux surface below addresses
344
+ # the real one, and the waiting marker (docker/claude/hooks/note-waiting.py)
345
+ # is named after it too.
346
+ MANAGED_PREFIX = "ali-"
347
+
348
+ # The console's session-name rule (webui.server), stated once so the raw
349
+ # tmux argv builders and the HTTP surface can never drift: a managed name
350
+ # starts with a letter or digit and carries only these characters, which is
351
+ # what keeps a `-flag`-shaped or shell-metacharacter name out of any argv.
352
+ SAFE_SESSION = re.compile(r"\A[A-Za-z0-9._][A-Za-z0-9._-]{0,199}\Z")
353
+
354
+
355
+ def tmux_socket() -> str:
356
+ """The path of the tmux server socket the reviewer sessions live on:
357
+ `$TMUX_TMPDIR/tmux-<uid>/default` (tmux's own default). Spelled out
358
+ rather than left to tmux because the daemon may run without `TMUX` in
359
+ its environment and must still reach the SAME server `alissa tmux new`
360
+ created for the reviewers -- same container, same user, same socket."""
361
+ base = os.environ.get("TMUX_TMPDIR", "").strip() or "/tmp"
362
+ return os.path.join(base, f"tmux-{os.getuid()}", "default")
363
+
364
+
365
+ def tmux_target(name: str) -> str:
366
+ """The `-t` target for a managed session: the real tmux name, prefixed
367
+ with `=` so tmux matches it EXACTLY (its default is prefix matching, and
368
+ `review-widgets-pr1-r1-abcdef` is a prefix of `…-r1-abcdef0`) and
369
+ suffixed with `:` so the target is the session's CURRENT WINDOW (and its
370
+ active pane) -- a bare `=name` is a session target, which `capture-pane`
371
+ and `send-keys` refuse with "can't find pane" (tmux 3.3a)."""
372
+ real = name if name.startswith(MANAGED_PREFIX) else MANAGED_PREFIX + name
373
+ return "=" + real + ":"
374
+
375
+
376
+ def check_session_name(name: "str | None") -> str:
377
+ """`name` when it passes SAFE_SESSION, else ValueError. Every raw tmux
378
+ argv builder calls this first."""
379
+ if not name or not SAFE_SESSION.match(name):
380
+ raise ValueError(f"invalid session name {name!r}")
381
+ return name
382
+
383
+
384
+ def capture_pane_argv(name: str, lines: int) -> "list[str]":
385
+ """`tmux -S <socket> capture-pane -p -t =<real> -S -<lines>`: the last
386
+ `lines` lines of the session's active pane, printed to stdout, with
387
+ escape sequences stripped (`-p` without `-e`)."""
388
+ check_session_name(name)
389
+ return [
390
+ "tmux", "-S", tmux_socket(), "capture-pane", "-p",
391
+ "-t", tmux_target(name), "-S", f"-{max(1, int(lines))}",
392
+ ]
393
+
394
+
395
+ def pane_path_argv(name: str) -> "list[str]":
396
+ check_session_name(name)
397
+ return [
398
+ "tmux", "-S", tmux_socket(), "display-message", "-p",
399
+ "-t", tmux_target(name), "#{pane_current_path}",
400
+ ]
401
+
402
+
403
+ def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
404
+ """`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
405
+ from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
406
+ is a ValueError before any process is spawned: the responder presses a
407
+ dialog's buttons and can never type into a reviewer."""
408
+ check_session_name(name)
409
+ if not keys:
410
+ raise ValueError("send_keys needs at least one key")
411
+ bad = [k for k in keys if k not in ALLOWED_KEYS]
412
+ if bad:
413
+ raise ValueError(
414
+ f"key(s) not in the allowlist {sorted(ALLOWED_KEYS)}: {bad!r}"
415
+ )
416
+ return ["tmux", "-S", tmux_socket(), "send-keys", "-t", tmux_target(name), *keys]
417
+
418
+
339
419
  # The reviewer-session GRAMMAR. The sweep kills sessions and the worker
340
420
  # container is shared with other lanes (`develop-*`, `fix-*`, `maintain-*`,
341
421
  # ...), so only a name that parses as one of the two shapes the review loop
@@ -419,11 +499,29 @@ class ManagedSession:
419
499
  # not report one -- treated as "long quiet", so a missing field can never
420
500
  # indefinitely immunize a session against the sweep.
421
501
  last_activity: float = 0.0
502
+ # The REAL tmux session name (`ali-<name>`, the roster's `session`
503
+ # field), or None when the CLI did not report one. The prompt responder
504
+ # names the waiting marker by it; absent, `MANAGED_PREFIX + name`.
505
+ session: "str | None" = None
422
506
 
423
507
  @property
424
508
  def is_idle(self) -> bool:
425
509
  return self.status == "idle"
426
510
 
511
+ @property
512
+ def tmux_name(self) -> str:
513
+ """The real tmux session name -- the roster's, else derived."""
514
+ return self.session or MANAGED_PREFIX + self.name
515
+
516
+ @property
517
+ def quiet_for(self) -> "float | None":
518
+ """Seconds since the last tmux activity, or None when the CLI
519
+ reported no timestamp (the sweep reads that 0 as "long quiet"; the
520
+ prompt responder reads it as "unknown" and waits for a marker)."""
521
+ if not self.last_activity:
522
+ return None
523
+ return max(0.0, time.time() - self.last_activity)
524
+
427
525
  @property
428
526
  def ref(self) -> "SessionRef | None":
429
527
  """What the name says about the round -- see parse_session_name."""
@@ -1117,11 +1215,13 @@ class Alissa:
1117
1215
  name = row.get("name")
1118
1216
  if isinstance(name, str) and parse_session_name(name) is not None:
1119
1217
  last = row.get("lastActivity")
1218
+ real = row.get("session")
1120
1219
  sessions.append(
1121
1220
  ManagedSession(
1122
1221
  name=name,
1123
1222
  status=str(row.get("status") or ""),
1124
1223
  last_activity=float(last) if isinstance(last, (int, float)) else 0.0,
1224
+ session=real if isinstance(real, str) and real else None,
1125
1225
  )
1126
1226
  )
1127
1227
  return sessions
@@ -1161,6 +1261,72 @@ class Alissa:
1161
1261
  log.debug("could not tail %s: %s", session, exc)
1162
1262
  return ""
1163
1263
 
1264
+ def capture_pane(self, name: str, lines: int = 40) -> str:
1265
+ """The last `lines` of ONE managed session's pane over raw tmux
1266
+ (`capture-pane -p`), or "" when tmux cannot answer (no such session,
1267
+ the socket gone, a timeout).
1268
+
1269
+ The responder's evidence seam (issue #138). Like `tail_session` it
1270
+ returns UNTRUSTED, possibly sensitive terminal content: the caller
1271
+ classifies it and lets only the classification -- plus a scrubbed
1272
+ signature line -- reach an operator-facing level. A READ, so it runs
1273
+ under dry-run too: a dry pass has to classify what a live one would.
1274
+ Raw tmux rather than the `alissa` CLI because the CLI's `tail` is a
1275
+ convenience without `send-keys` beside it, and the answer has to land
1276
+ on the very pane the capture read."""
1277
+ try:
1278
+ argv = capture_pane_argv(name, lines)
1279
+ except ValueError as exc:
1280
+ log.warning("capture_pane refused: %s", exc)
1281
+ return ""
1282
+ try:
1283
+ out = run(argv, timeout=15)
1284
+ except CommandError as exc:
1285
+ log.debug("could not capture the pane of %s (%s)", name, exc)
1286
+ return ""
1287
+ return out.rstrip("\n")
1288
+
1289
+ def pane_path(self, name: str) -> str:
1290
+ """The session's pane current path (`#{pane_current_path}`), or ""
1291
+ when tmux cannot answer. One leg of the review-checkout resolution:
1292
+ a reviewer that `cd`ed into its `REVIEW-*` checkout names it here."""
1293
+ try:
1294
+ argv = pane_path_argv(name)
1295
+ except ValueError as exc:
1296
+ log.warning("pane_path refused: %s", exc)
1297
+ return ""
1298
+ try:
1299
+ return run(argv, timeout=15).strip()
1300
+ except CommandError as exc:
1301
+ log.debug("could not read the pane path of %s (%s)", name, exc)
1302
+ return ""
1303
+
1304
+ def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
1305
+ """Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
1306
+ -- on ONE managed session's pane. True when tmux accepted them.
1307
+
1308
+ The ONLY key-pressing surface in this daemon, and deliberately
1309
+ narrow: an allowlisted enum, a validated session name, an exact-match
1310
+ target, one `send-keys` per call. A key outside the allowlist is
1311
+ refused before any process is spawned and logged at WARNING (it is a
1312
+ programming error, never a pane's doing -- keys come from the
1313
+ policy table, not from the screen). Dry-run logs the intent and
1314
+ sends nothing."""
1315
+ try:
1316
+ argv = send_keys_argv(name, tuple(keys))
1317
+ except ValueError as exc:
1318
+ log.warning("send_keys refused for %s: %s", name, exc)
1319
+ return False
1320
+ if dry_run:
1321
+ log.info("[dry-run] would send keys %s to %s", " ".join(keys), name)
1322
+ return False
1323
+ try:
1324
+ run(argv, timeout=15)
1325
+ except CommandError as exc:
1326
+ log.warning("could not send keys %s to %s (%s)", " ".join(keys), name, exc)
1327
+ return False
1328
+ return True
1329
+
1164
1330
  def add_repo_to_workspace(
1165
1331
  self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
1166
1332
  ) -> None:
@@ -35,6 +35,7 @@ from __future__ import annotations
35
35
 
36
36
  import fnmatch
37
37
  import json
38
+ import logging
38
39
  import os
39
40
  import re
40
41
  from dataclasses import dataclass, field
@@ -42,6 +43,8 @@ from pathlib import Path
42
43
  from typing import Any, Mapping
43
44
  from urllib.parse import urlsplit
44
45
 
46
+ log = logging.getLogger(__name__)
47
+
45
48
  # A POSIX-ish environment variable name -- what `reviewer_token_env` must be.
46
49
  _ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
47
50
 
@@ -196,6 +199,11 @@ CONFIG_KEYS = (
196
199
  "task_list_bow_id",
197
200
  "loop_events_enabled",
198
201
  "fleet_vitals_enabled",
202
+ "prompt_responder",
203
+ "prompt_quiet_seconds",
204
+ "prompt_kill_minutes",
205
+ "prompt_max_answers",
206
+ "waiting_dir",
199
207
  "alissa_endpoint",
200
208
  "dry_run",
201
209
  )
@@ -454,6 +462,20 @@ def _validate_actor_id(entry: str) -> None:
454
462
 
455
463
  MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
456
464
 
465
+ # The prompt responder's master switch (issue #138; devloop #127's names):
466
+ # `on` answers, `observe` classifies and narrates but sends nothing, `off`
467
+ # never captures a pane.
468
+ PROMPT_ON = "on"
469
+ PROMPT_OBSERVE = "observe"
470
+ PROMPT_OFF = "off"
471
+ _PROMPT_MODES = frozenset((PROMPT_ON, PROMPT_OBSERVE, PROMPT_OFF))
472
+
473
+ # Where the container's hooks leave the "this session is waiting for input"
474
+ # marker (`docker/claude/hooks/note-waiting.py`): `${ALISSA_WAITING_DIR}/<tmux
475
+ # session>.json`. The daemon reads the same directory, so the two halves
476
+ # agree by construction on a default deployment.
477
+ DEFAULT_WAITING_DIR = "/workspace/.waiting"
478
+
457
479
  # A reviewer session that has not submitted after this long is presumed dead
458
480
  # (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
459
481
  # but only with a second signal agreeing: the timer alone cannot tell a dead
@@ -853,6 +875,40 @@ class Config:
853
875
  # about the client (its bearer token) comes from the environment.
854
876
  alissa_endpoint: str = DEFAULT_ALISSA_ENDPOINT
855
877
 
878
+ # The prompt responder (issue #138; devloop #127's knobs under the same
879
+ # names): every poll, for every ALIVE reviewer session this daemon owns,
880
+ # a waiting marker from the container's hook OR a session quiet for
881
+ # `prompt_quiet_seconds` triggers a pane capture, the classifier and the
882
+ # policy table (`prompts.py`), and the answer lands in seconds -- no
883
+ # SSH, no LLM in the loop. `on` (the default) answers; `observe`
884
+ # classifies and narrates but sends no key, kills nothing and pages
885
+ # nobody; `off` never captures a pane. Every side effect honours dry_run
886
+ # (a dry pass classifies and logs, sends nothing).
887
+ prompt_responder: str = PROMPT_ON
888
+
889
+ # How long a reviewer session must have been quiet (`lastActivity` in
890
+ # the roster) before its pane is read WITHOUT a waiting marker. The
891
+ # marker is the fast path (seconds); this is the net under it, for a
892
+ # hook that did not fire. Floored at 30 so a session between two tool
893
+ # calls is not read as parked.
894
+ prompt_quiet_seconds: int = 90
895
+
896
+ # The unknown-dialog ladder's last rung: a dialog no signature knows is
897
+ # waited out one poll, dismissed with Escape on its second sighting, and
898
+ # after this many minutes still on screen the session is killed so the
899
+ # ordinary stale-round re-entry takes over. Floored at 1.
900
+ prompt_kill_minutes: int = 10
901
+
902
+ # The per-session answer cap: after this many keystroke answers the
903
+ # session is killed and its round re-queued -- a reviewer that keeps
904
+ # producing prompts is diverging, and the narration says so. Floored at 1.
905
+ prompt_max_answers: int = 5
906
+
907
+ # Where the waiting markers are (see DEFAULT_WAITING_DIR). The hooks
908
+ # read ALISSA_WAITING_DIR at run time; the container's config renderer
909
+ # writes the same value here so the daemon looks where the hooks wrote.
910
+ waiting_dir: str = DEFAULT_WAITING_DIR
911
+
856
912
  dry_run: bool = False
857
913
 
858
914
  def __post_init__(self) -> None:
@@ -875,6 +931,23 @@ class Config:
875
931
  def manifest_path(self) -> Path:
876
932
  return self.workspace_root / "alissa-workspace.yaml"
877
933
 
934
+ @property
935
+ def prompt_kill_seconds(self) -> int:
936
+ """The unknown-dialog kill rung in seconds, floored at one minute
937
+ here as well as in `build()` so a Config assembled without it can
938
+ never kill a session on its first sighting."""
939
+ return max(1, self.prompt_kill_minutes) * 60
940
+
941
+ @property
942
+ def prompt_quiet_floor(self) -> int:
943
+ """`prompt_quiet_seconds` with the 30 s floor applied again
944
+ (`build()` clamps, and so does the read)."""
945
+ return max(30, self.prompt_quiet_seconds)
946
+
947
+ @property
948
+ def prompt_answer_cap(self) -> int:
949
+ return max(1, self.prompt_max_answers)
950
+
878
951
  def hub_for(self, owner: str, repo: str) -> Path:
879
952
  return Path(
880
953
  self.hub_template.format(
@@ -1217,6 +1290,47 @@ class Config:
1217
1290
  f"token — got {_describe(token_env)}.{rotate}"
1218
1291
  )
1219
1292
 
1293
+ # The prompt responder's knobs (issue #138). The mode is an enum
1294
+ # validated by NAME like repos_source (a typo must not read as
1295
+ # "off"); the three numbers have FLOORS rather than 0-sentinels --
1296
+ # none of them has a meaningful "off" value, and each floor is the
1297
+ # smallest value that cannot answer a session that is merely between
1298
+ # two tool calls -- and are clamped up with a warning, because none of
1299
+ # them is worth failing a boot.
1300
+ prompt_mode = str(raw.get("prompt_responder", cls.prompt_responder)).strip()
1301
+ if prompt_mode not in _PROMPT_MODES:
1302
+ raise ValueError(
1303
+ f"prompt_responder must be one of {sorted(_PROMPT_MODES)}, "
1304
+ f"got {prompt_mode!r}"
1305
+ )
1306
+ prompt_quiet = int(raw.get("prompt_quiet_seconds", cls.prompt_quiet_seconds))
1307
+ if prompt_quiet < 30:
1308
+ log.warning(
1309
+ "prompt_quiet_seconds=%d is below the 30 s floor — a shorter "
1310
+ "window would read a session between two tool calls as "
1311
+ "parked; clamping up to 30", prompt_quiet,
1312
+ )
1313
+ prompt_quiet = 30
1314
+ prompt_kill = int(raw.get("prompt_kill_minutes", cls.prompt_kill_minutes))
1315
+ if prompt_kill < 1:
1316
+ log.warning(
1317
+ "prompt_kill_minutes=%d is below the 1 min floor — the "
1318
+ "unknown-dialog ladder needs at least one poll between the "
1319
+ "dismissal and the kill; clamping up to 1", prompt_kill,
1320
+ )
1321
+ prompt_kill = 1
1322
+ prompt_answers = int(raw.get("prompt_max_answers", cls.prompt_max_answers))
1323
+ if prompt_answers < 1:
1324
+ log.warning(
1325
+ "prompt_max_answers=%d is below the floor of 1 — a cap of 0 "
1326
+ "would kill every session on its first prompt; clamping up "
1327
+ "to 1", prompt_answers,
1328
+ )
1329
+ prompt_answers = 1
1330
+ waiting_dir = str(raw.get("waiting_dir", cls.waiting_dir)).strip()
1331
+ if not waiting_dir:
1332
+ raise ValueError("waiting_dir must be a non-empty path")
1333
+
1220
1334
  state_path = raw.get("state_path")
1221
1335
  return cls(
1222
1336
  workspace_root=Path(workspace_root),
@@ -1248,6 +1362,11 @@ class Config:
1248
1362
  task_list_bow_id=bow_id,
1249
1363
  loop_events_enabled=bool(raw.get("loop_events_enabled", False)),
1250
1364
  fleet_vitals_enabled=bool(raw.get("fleet_vitals_enabled", False)),
1365
+ prompt_responder=prompt_mode,
1366
+ prompt_quiet_seconds=prompt_quiet,
1367
+ prompt_kill_minutes=prompt_kill,
1368
+ prompt_max_answers=prompt_answers,
1369
+ waiting_dir=waiting_dir,
1251
1370
  alissa_endpoint=endpoint,
1252
1371
  dry_run=bool(raw.get("dry_run", False)),
1253
1372
  )