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.
- {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
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/alissa.py +147 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/config.py +114 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/loop.py +697 -12
- {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
- alissa_tools_github_devloop-0.8.26/src/main/alissa/tools/github/devloop/prompts.py +761 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/state.py +146 -0
- alissa_tools_github_devloop-0.8.26/src/main/alissa/tools/github/devloop/version +1 -0
- {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
- {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
- {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
- {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
- {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
- alissa_tools_github_devloop-0.8.25/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
- {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
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {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
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.26}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
|
@@ -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
|
),
|