switchroom 0.21.6 → 0.21.8

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 (43) hide show
  1. package/bin/autoaccept.exp +21 -1
  2. package/bin/tmp-reaper.sh +234 -0
  3. package/dist/agent-scheduler/index.js +1 -1
  4. package/dist/auth-broker/index.js +2 -2
  5. package/dist/cli/autoaccept-poll.js +95 -7
  6. package/dist/cli/notion-write-pretool.mjs +1 -1
  7. package/dist/cli/switchroom.js +3432 -2751
  8. package/dist/host-control/main.js +177 -13
  9. package/dist/vault/approvals/kernel-server.js +2 -2
  10. package/dist/vault/broker/server.js +2 -2
  11. package/package.json +6 -4
  12. package/profiles/_base/start.sh.hbs +115 -0
  13. package/profiles/_shared/local-time.md.hbs +6 -0
  14. package/profiles/default/CLAUDE.md.hbs +0 -12
  15. package/telegram-plugin/bunfig.toml +12 -4
  16. package/telegram-plugin/dist/gateway/gateway.js +1017 -465
  17. package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
  18. package/telegram-plugin/gateway/approval-hold.ts +32 -1
  19. package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
  20. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
  21. package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
  22. package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
  23. package/telegram-plugin/gateway/gateway.ts +12 -10
  24. package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
  25. package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
  26. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
  27. package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
  28. package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
  29. package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
  30. package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
  31. package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
  32. package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
  33. package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
  34. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +7 -1
  35. package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
  36. package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
  37. package/telegram-plugin/tests/parked-turn-start-preload.test.ts +65 -0
  38. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
  39. package/telegram-plugin/tests/queued-card-surface.test.ts +9 -1
  40. package/telegram-plugin/tests/stream-render-golden.test.ts +10 -1
  41. package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
  42. package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +7 -1
  43. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +7 -1
@@ -86,8 +86,28 @@ expect {
86
86
  send "\r"
87
87
  exp_continue
88
88
  }
89
+ -re {Continue with Fable} {
90
+ # Fable-5 usage-credits consent modal:
91
+ # Fable 5 runs on usage credits, purchased separately from your plan.
92
+ # 1. Continue with Fable 5
93
+ # ❯ 2. Switch to Sonnet 5 and continue
94
+ # Enter to confirm · Esc to cancel
95
+ # MUST precede the generic "Enter to confirm" branch below: that
96
+ # footer matches this modal too, and the CLI focuses option 2 by
97
+ # default (`defaultFocusValue:"switch"`), so a bare Return silently
98
+ # declines Fable and downgrades the agent to Sonnet. Select option 1
99
+ # by digit instead. Kept in sync with the `fable-usage-credits-consent`
100
+ # rule in src/agents/autoaccept.ts.
101
+ sleep 0.5
102
+ send "1\r"
103
+ exp_continue
104
+ }
89
105
  -re {Enter.{1,30}confirm} {
90
- # Generic "Enter to confirm" prompt (dev channels, trust dialog)
106
+ # Generic "Enter to confirm" prompt (dev channels, trust dialog).
107
+ # NOTE: the TS poller additionally guards this catch-all against
108
+ # numbered selectors (NUMBERED_CHOICE_SIGNATURE). expect matches a
109
+ # STREAM in branch order, so here the equivalent protection is
110
+ # ordering: every option-selecting branch must sit above this one.
91
111
  sleep 0.8
92
112
  send "\r"
93
113
  exp_continue
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env bash
2
+ # switchroom-tmp-reaper — bounded janitor for the agent container's /tmp tmpfs.
3
+ #
4
+ # WHY THIS EXISTS
5
+ # ---------------
6
+ # An agent container mounts /tmp as a RAM-backed tmpfs, sized by
7
+ # `resources.tmp_size` (src/agents/compose.ts, DEFAULT_TMP_SIZE = 2g), with
8
+ # Docker's default tmpfs flags — `rw,nosuid,nodev,noexec`. Two consequences:
9
+ #
10
+ # 1. It is a hard ceiling. Nothing ages out of a tmpfs; a long-running
11
+ # container accumulates every scratch dir any tool ever forgot to
12
+ # remove until writes start failing ENOSPC. Observed on a live agent:
13
+ # 4,462 top-level entries, 490 MiB used, of which 204 MiB was two
14
+ # orphaned `bun build --compile` artifacts from a single test run
15
+ # (fixed at source in tests/install/static-binary.test.ts, but that
16
+ # only fixes the leak we already found).
17
+ # 2. The failure is NOT localised. Once /tmp is full, everything that
18
+ # stages through it fails — `npm ci`, git, the claude CLI's own
19
+ # scratch — with errors that name the victim, never the cause.
20
+ #
21
+ # The size is deliberately NOT the lever. Raising the tmpfs converts a fast,
22
+ # loud failure into a slow leak that eats host RAM instead. This reaper
23
+ # bounds the accumulation itself.
24
+ #
25
+ # SAFETY CONTRACT (each clause is pinned by tests/tmp-reaper.test.ts)
26
+ # -------------------------------------------------------------------
27
+ # * Scope. Only direct children of the root are considered, the root
28
+ # itself is never removed, and every traversal is `-xdev` with no
29
+ # symlink dereference — so a symlink under /tmp pointing at, say,
30
+ # /state/agent can have the LINK reaped but never its target.
31
+ # * Age. An entry is a candidate only when NOTHING anywhere in its
32
+ # subtree has been modified or accessed within the age threshold
33
+ # (default 24h). A directory's own mtime does not change when a
34
+ # grandchild is written, so checking the entry alone would reap a
35
+ # live tree — the whole subtree is checked.
36
+ # * Open files. An entry is skipped when it, or anything beneath it, is
37
+ # open by any process in the container: /proc/*/fd, /proc/*/cwd,
38
+ # /proc/*/exe and mmapped regions from /proc/*/maps are all consulted.
39
+ # * Logging. Every reaped entry is logged with its size and age, and every
40
+ # pass emits a one-line summary — including passes that reap nothing.
41
+ #
42
+ # TUNING (all validated; garbage falls back to the default)
43
+ # SWITCHROOM_TMP_REAPER=0 disable entirely
44
+ # SWITCHROOM_TMP_REAPER_MIN_AGE_SEC=86400 age threshold (min 3600)
45
+ # SWITCHROOM_TMP_REAPER_INTERVAL_SEC=3600 seconds between passes
46
+ # SWITCHROOM_TMP_REAPER_ROOT=/tmp root (test harness only)
47
+ #
48
+ # Run standalone for a one-shot pass: tmp-reaper.sh --once
49
+ set -uo pipefail
50
+
51
+ REAPER_NAME="switchroom-tmp-reaper"
52
+
53
+ TMP_REAPER_ROOT="${SWITCHROOM_TMP_REAPER_ROOT:-/tmp}"
54
+
55
+ # Age below which an entry is NEVER touched. One hour is the hard floor even
56
+ # if an operator sets something smaller: this reaper cannot distinguish "a
57
+ # build wrote this five minutes ago and will read it back in ten" from
58
+ # garbage, and the age bound is the only thing standing between it and a
59
+ # live workload.
60
+ MIN_AGE_SEC="${SWITCHROOM_TMP_REAPER_MIN_AGE_SEC:-86400}"
61
+ case "$MIN_AGE_SEC" in ''|*[!0-9]*) MIN_AGE_SEC=86400;; esac
62
+ [ "$MIN_AGE_SEC" -lt 3600 ] && MIN_AGE_SEC=3600
63
+
64
+ INTERVAL_SEC="${SWITCHROOM_TMP_REAPER_INTERVAL_SEC:-3600}"
65
+ case "$INTERVAL_SEC" in ''|0|*[!0-9]*) INTERVAL_SEC=3600;; esac
66
+ [ "$INTERVAL_SEC" -lt 60 ] && INTERVAL_SEC=60
67
+
68
+ # Never-reap names. These are conventional tmpfs fixtures whose mtime can sit
69
+ # still for weeks while they are very much in use, and whose removal breaks
70
+ # things in ways that are hard to trace back here.
71
+ TMP_REAPER_KEEP_DEFAULT=".X11-unix .ICE-unix .font-unix .Test-unix .XIM-unix"
72
+ TMP_REAPER_KEEP="${SWITCHROOM_TMP_REAPER_KEEP:-$TMP_REAPER_KEEP_DEFAULT}"
73
+
74
+ _log() { printf '[%s] %s %s\n' "$REAPER_NAME" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
75
+
76
+ # Refuse to operate on a root that is not a plain, existing directory, or that
77
+ # is the filesystem root. The default is /tmp and the override exists for the
78
+ # test harness; this guard is what keeps a mis-set override from being
79
+ # catastrophic rather than merely wrong.
80
+ _root_ok() {
81
+ local r="$1"
82
+ [ -n "$r" ] || { _log "refusing: empty root"; return 1; }
83
+ [ "$r" != "/" ] || { _log "refusing: root is /"; return 1; }
84
+ case "$r" in
85
+ /*) ;;
86
+ *) _log "refusing: root '$r' is not absolute"; return 1;;
87
+ esac
88
+ case "$r" in
89
+ *..*) _log "refusing: root '$r' contains '..'"; return 1;;
90
+ esac
91
+ [ -d "$r" ] || { _log "refusing: root '$r' is not a directory"; return 1; }
92
+ [ ! -L "$r" ] || { _log "refusing: root '$r' is a symlink"; return 1; }
93
+ return 0
94
+ }
95
+
96
+ # Emit every path under $1 that some process in this container currently has
97
+ # open — as an fd, as its cwd, as its executable, or as an mmapped region.
98
+ # Unreadable /proc entries (a process owned by another uid, or one that exited
99
+ # mid-scan) are skipped: they can only cause us to reap LESS, never more.
100
+ #
101
+ # Cost matters here: a busy container has hundreds of processes and thousands
102
+ # of fds, so this uses ONE `find` (with -printf '%l' to read the link targets
103
+ # in-process) and ONE `grep`+`awk` pipeline over the maps files, rather than a
104
+ # readlink fork per fd. A per-fd fork loop measured multiple seconds per pass.
105
+ #
106
+ # CRITICAL: neither traversal may ABORT on an unreadable input. `find` reports
107
+ # a vanished/denied start point and keeps going, which is what we need. `awk`
108
+ # does NOT: the image ships mawk 1.3.4, which stops at the FIRST file it cannot
109
+ # open and never reads the rest of its argument list. Handing mawk the raw
110
+ # /proc/[0-9]*/maps glob therefore meant that one PID exiting between bash's
111
+ # glob expansion and mawk's serial open (ESRCH), or one EPERM, silently
112
+ # discarded every LATER process's mappings — a fail-OPEN in a safety guard,
113
+ # after which the reaper would delete a tree a live process still had mapped.
114
+ # So `grep` (GNU grep continues past unreadable files, exit 2) does the
115
+ # multi-file read and `awk` only ever sees a single stdin stream.
116
+ #
117
+ # `awk substr` rather than `grep -o`: a maps line is
118
+ # "addr perms offset dev inode <pathname>", so the path runs to end-of-line and
119
+ # MAY CONTAIN SPACES. `grep -oE "$root/[^ ]*"` would truncate
120
+ # "/tmp/my dir/lib.so" to "/tmp/my", which no longer prefix-matches the entry
121
+ # in the caller below — fail-open again, for any /tmp entry with a space.
122
+ _open_paths() {
123
+ local root="$1"
124
+ find /proc/[0-9]*/fd /proc/[0-9]*/cwd /proc/[0-9]*/exe \
125
+ -maxdepth 1 -type l -printf '%l\n' 2>/dev/null \
126
+ | grep -F -- "$root/" 2>/dev/null
127
+ # mmapped files (a running binary, a memory-mapped DB) do not appear as an
128
+ # fd once mapped and the fd is closed, but are absolutely still in use.
129
+ grep -haF -- "$root/" /proc/[0-9]*/maps 2>/dev/null \
130
+ | awk -v r="$root/" '{ p=index($0, r); if (p) print substr($0, p) }'
131
+ }
132
+
133
+ _human() {
134
+ local b="${1:-0}"
135
+ if [ "$b" -ge 1073741824 ] 2>/dev/null; then echo "$((b / 1073741824))G"
136
+ elif [ "$b" -ge 1048576 ] 2>/dev/null; then echo "$((b / 1048576))M"
137
+ elif [ "$b" -ge 1024 ] 2>/dev/null; then echo "$((b / 1024))K"
138
+ else echo "${b}B"; fi
139
+ }
140
+
141
+ # One pass. Returns 0 always; a failure to remove one entry must not abort
142
+ # the pass (the next entry may be the one actually holding the space).
143
+ tmp_reaper_pass() {
144
+ local root="$TMP_REAPER_ROOT"
145
+ _root_ok "$root" || return 1
146
+ # Canonicalise. /proc/*/fd link targets are already fully resolved, so an
147
+ # un-canonicalised root with a symlinked ancestor would never prefix-match
148
+ # them and the open-file check would silently pass everything through —
149
+ # the guard would look present and do nothing.
150
+ root=$(readlink -f -- "$root" 2>/dev/null) || root="$TMP_REAPER_ROOT"
151
+ [ -n "$root" ] || root="$TMP_REAPER_ROOT"
152
+ _root_ok "$root" || return 1
153
+
154
+ local now cutoff
155
+ now=$(date +%s)
156
+ cutoff=$((now - MIN_AGE_SEC))
157
+
158
+ local open_list
159
+ open_list=$(_open_paths "$root")
160
+
161
+ local reaped=0 freed=0 kept_open=0 kept_young=0 kept_pinned=0 failed=0
162
+ local entry base
163
+
164
+ while IFS= read -r -d '' entry; do
165
+ base="${entry##*/}"
166
+
167
+ # 1. Never-reap list.
168
+ local pinned=0 k
169
+ for k in $TMP_REAPER_KEEP; do
170
+ [ "$base" = "$k" ] && { pinned=1; break; }
171
+ done
172
+ if [ "$pinned" -eq 1 ]; then kept_pinned=$((kept_pinned + 1)); continue; fi
173
+
174
+ # 2. Age — nothing ANYWHERE in the subtree may be newer than the cutoff,
175
+ # by mtime or atime. `-print -quit` stops at the first offender.
176
+ local young
177
+ young=$(find "$entry" -xdev \( -newermt "@$cutoff" -o -newerat "@$cutoff" \) \
178
+ -print -quit 2>/dev/null)
179
+ if [ -n "$young" ]; then kept_young=$((kept_young + 1)); continue; fi
180
+
181
+ # 3. Open by a live process (the entry itself, or anything under it).
182
+ local is_open=0 p
183
+ while IFS= read -r p; do
184
+ [ -n "$p" ] || continue
185
+ if [ "$p" = "$entry" ] || [ "${p#"$entry"/}" != "$p" ]; then
186
+ is_open=1; break
187
+ fi
188
+ done <<< "$open_list"
189
+ if [ "$is_open" -eq 1 ]; then kept_open=$((kept_open + 1)); continue; fi
190
+
191
+ # Size and age for the log line, computed BEFORE removal.
192
+ local bytes mtime age_h
193
+ bytes=$(du -sxb -- "$entry" 2>/dev/null | cut -f1)
194
+ case "$bytes" in ''|*[!0-9]*) bytes=0;; esac
195
+ mtime=$(stat -c %Y -- "$entry" 2>/dev/null || echo "$now")
196
+ age_h=$(( (now - mtime) / 3600 ))
197
+
198
+ if rm -rf --one-file-system --preserve-root -- "$entry" 2>/dev/null; then
199
+ reaped=$((reaped + 1))
200
+ freed=$((freed + bytes))
201
+ _log "reaped $entry ($(_human "$bytes"), idle ${age_h}h)"
202
+ else
203
+ failed=$((failed + 1))
204
+ _log "could NOT remove $entry ($(_human "$bytes"), idle ${age_h}h)"
205
+ fi
206
+ done < <(find "$root" -xdev -mindepth 1 -maxdepth 1 -print0 2>/dev/null)
207
+
208
+ _log "pass complete on $root: reaped=$reaped freed=$(_human "$freed")" \
209
+ "kept_young=$kept_young kept_open=$kept_open kept_pinned=$kept_pinned" \
210
+ "failed=$failed min_age=${MIN_AGE_SEC}s"
211
+ return 0
212
+ }
213
+
214
+ tmp_reaper_main() {
215
+ if [ "${SWITCHROOM_TMP_REAPER:-1}" = "0" ]; then
216
+ _log "disabled via SWITCHROOM_TMP_REAPER=0 — not starting"
217
+ return 0
218
+ fi
219
+ if [ "${1:-}" = "--once" ]; then
220
+ tmp_reaper_pass
221
+ return $?
222
+ fi
223
+ _log "starting: root=$TMP_REAPER_ROOT min_age=${MIN_AGE_SEC}s interval=${INTERVAL_SEC}s"
224
+ while true; do
225
+ tmp_reaper_pass
226
+ sleep "$INTERVAL_SEC"
227
+ done
228
+ }
229
+
230
+ # Only auto-run when EXECUTED. Sourcing (the test harness, and any future
231
+ # caller that wants a single pass inline) gets the functions and nothing else.
232
+ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
233
+ tmp_reaper_main "$@"
234
+ fi
@@ -11738,7 +11738,7 @@ var AgentSchema = exports_external.object({
11738
11738
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
11739
11739
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
11740
11740
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
11741
- })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated worktree for each " + "repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main, backed by a " + "shared bare clone at ~/.switchroom/repos/<slug>.git. The worktree path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
11741
+ })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated standing tree for " + "each repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main an " + "independent clone (own refs/stash, hardlinked objects) seeded from a shared bare " + "clone at ~/.switchroom/repos/<slug>.git. The tree's path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
11742
11742
  experimental: exports_external.object({
11743
11743
  legacy_pty: exports_external.boolean().optional().describe("Opt out of the default tmux supervisor (#725) and run the agent " + "under the legacy PTY supervisor instead. Default: false."),
11744
11744
  legacy_autoaccept_expect: exports_external.boolean().optional().describe("Opt the autoaccept gateway back into the legacy expect-script " + "behaviour instead of the tmux send-keys path. Default: false.")
@@ -11763,7 +11763,7 @@ var init_schema = __esm(() => {
11763
11763
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
11764
11764
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
11765
11765
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
11766
- })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated worktree for each " + "repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main, backed by a " + "shared bare clone at ~/.switchroom/repos/<slug>.git. The worktree path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
11766
+ })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated standing tree for " + "each repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main an " + "independent clone (own refs/stash, hardlinked objects) seeded from a shared bare " + "clone at ~/.switchroom/repos/<slug>.git. The tree's path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
11767
11767
  experimental: exports_external.object({
11768
11768
  legacy_pty: exports_external.boolean().optional().describe("Opt out of the default tmux supervisor (#725) and run the agent " + "under the legacy PTY supervisor instead. Default: false."),
11769
11769
  legacy_autoaccept_expect: exports_external.boolean().optional().describe("Opt the autoaccept gateway back into the legacy expect-script " + "behaviour instead of the tmux send-keys path. Default: false.")
@@ -19111,7 +19111,7 @@ import_handlebars.default.registerHelper("isNumber", (value) => {
19111
19111
  return typeof value === "number" && Number.isFinite(value);
19112
19112
  });
19113
19113
  var SHARED_FRAGMENTS_DIR = resolve5(PROFILES_ROOT, "_shared");
19114
- var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol"];
19114
+ var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol", "local-time"];
19115
19115
  for (const name of SHARED_FRAGMENTS) {
19116
19116
  const fragPath = join2(SHARED_FRAGMENTS_DIR, `${name}.md.hbs`);
19117
19117
  if (existsSync5(fragPath)) {
@@ -20,6 +20,29 @@ import { execFileSync as execFileSync3 } from "child_process";
20
20
 
21
21
  // src/agents/autoaccept.ts
22
22
  import { execFileSync } from "node:child_process";
23
+ var NUMBERED_CHOICE_SIGNATURE = /(?=[\s\S]*^[^\S\n]*(?:\u276f[^\S\n]*)?1\.[^\S\n]+\S)(?=[\s\S]*^[^\S\n]*(?:\u276f[^\S\n]*)?2\.[^\S\n]+\S)/m;
24
+ var FABLE_CONSENT_SIGNATURE = /(?=[\s\S]*Continue with Fable)(?=[\s\S]*usage credits)/i;
25
+ function fableConsentNav(text) {
26
+ for (const line of text.split(`
27
+ `)) {
28
+ const m = line.match(/^\s*(\u276f)?\s*(\d+)\.\s+(.*\S)\s*$/);
29
+ if (!m)
30
+ continue;
31
+ if (/Continue with Fable/i.test(m[3]))
32
+ return [m[2], "Enter"];
33
+ }
34
+ return null;
35
+ }
36
+ function ruleMatches(rule, text) {
37
+ if (!rule.match.test(text))
38
+ return false;
39
+ if (rule.notMatch && rule.notMatch.test(text))
40
+ return false;
41
+ return true;
42
+ }
43
+ function resolveRuleKeys(rule, text) {
44
+ return rule.keysFor?.(text) ?? rule.keys;
45
+ }
23
46
  var PROMPTS = [
24
47
  {
25
48
  name: "dev-channels-loading",
@@ -57,9 +80,16 @@ var PROMPTS = [
57
80
  keys: ["Escape"],
58
81
  maxFires: 5
59
82
  },
83
+ {
84
+ name: "fable-usage-credits-consent",
85
+ match: FABLE_CONSENT_SIGNATURE,
86
+ keysFor: fableConsentNav,
87
+ keys: ["1", "Enter"]
88
+ },
60
89
  {
61
90
  name: "enter-to-confirm",
62
91
  match: /Enter.{1,30}confirm/,
92
+ notMatch: NUMBERED_CHOICE_SIGNATURE,
63
93
  keys: ["Enter"]
64
94
  }
65
95
  ];
@@ -117,12 +147,13 @@ async function runAutoaccept(opts) {
117
147
  for (const entry of rules) {
118
148
  if (entry.fired >= entry.cap)
119
149
  continue;
120
- if (entry.rule.match.test(text)) {
150
+ if (ruleMatches(entry.rule, text)) {
151
+ const keys = resolveRuleKeys(entry.rule, text);
121
152
  entry.fired++;
122
153
  matchedThisPoll = true;
123
154
  fired.push(entry.rule.name);
124
- console.error(`[autoaccept] ${opts.agentName}: fired ${entry.rule.name} (${entry.rule.keys.join("+")})`);
125
- sendKeys(opts.agentName, entry.rule.keys);
155
+ console.error(`[autoaccept] ${opts.agentName}: fired ${entry.rule.name} (${keys.join("+")})`);
156
+ sendKeys(opts.agentName, keys);
126
157
  }
127
158
  }
128
159
  }
@@ -141,6 +172,26 @@ async function runAutoaccept(opts) {
141
172
 
142
173
  // src/agents/autoaccept.ts
143
174
  import { execFileSync as execFileSync2 } from "node:child_process";
175
+ var NUMBERED_CHOICE_SIGNATURE2 = /(?=[\s\S]*^[^\S\n]*(?:\u276f[^\S\n]*)?1\.[^\S\n]+\S)(?=[\s\S]*^[^\S\n]*(?:\u276f[^\S\n]*)?2\.[^\S\n]+\S)/m;
176
+ var FABLE_CONSENT_SIGNATURE2 = /(?=[\s\S]*Continue with Fable)(?=[\s\S]*usage credits)/i;
177
+ function fableConsentNav2(text) {
178
+ for (const line of text.split(`
179
+ `)) {
180
+ const m = line.match(/^\s*(\u276f)?\s*(\d+)\.\s+(.*\S)\s*$/);
181
+ if (!m)
182
+ continue;
183
+ if (/Continue with Fable/i.test(m[3]))
184
+ return [m[2], "Enter"];
185
+ }
186
+ return null;
187
+ }
188
+ function ruleMatches2(rule, text) {
189
+ if (!rule.match.test(text))
190
+ return false;
191
+ if (rule.notMatch && rule.notMatch.test(text))
192
+ return false;
193
+ return true;
194
+ }
144
195
  var PROMPTS2 = [
145
196
  {
146
197
  name: "dev-channels-loading",
@@ -178,9 +229,16 @@ var PROMPTS2 = [
178
229
  keys: ["Escape"],
179
230
  maxFires: 5
180
231
  },
232
+ {
233
+ name: "fable-usage-credits-consent",
234
+ match: FABLE_CONSENT_SIGNATURE2,
235
+ keysFor: fableConsentNav2,
236
+ keys: ["1", "Enter"]
237
+ },
181
238
  {
182
239
  name: "enter-to-confirm",
183
240
  match: /Enter.{1,30}confirm/,
241
+ notMatch: NUMBERED_CHOICE_SIGNATURE2,
184
242
  keys: ["Enter"]
185
243
  }
186
244
  ];
@@ -613,6 +671,7 @@ var DEFAULT_STABILITY_THRESHOLD = 3;
613
671
  var DEFAULT_COOLDOWN_MS = 60000;
614
672
  var DEFAULT_CONFIRM_MODAL_POLLS = 3;
615
673
  var DEFAULT_MANIFEST_STALL_POLLS = 60;
674
+ var DEFAULT_FABLE_CONSENT_POLLS = 3;
616
675
  var DEFAULT_PERMISSION_PROMPT_POLLS = 3;
617
676
  var DEFAULT_PERMISSION_CARDLESS_POLLS = 24;
618
677
  var DEFAULT_PERMISSION_FLOOD_MAX_POLLS = 120;
@@ -643,6 +702,8 @@ async function runWedgeWatchdog(opts) {
643
702
  const parseReset = opts.parseReset ?? parseWeeklyReset;
644
703
  const confirmModalSignature = opts.confirmModalSignature === null ? null : opts.confirmModalSignature ?? CONFIRM_MODAL_SIGNATURE;
645
704
  const confirmModalPolls = opts.confirmModalPolls ?? envInt("SWITCHROOM_WEDGE_CONFIRM_POLLS", DEFAULT_CONFIRM_MODAL_POLLS);
705
+ const fableConsentSignature = opts.fableConsentSignature === null ? null : opts.fableConsentSignature ?? FABLE_CONSENT_SIGNATURE2;
706
+ const fableConsentPolls = opts.fableConsentPolls ?? envInt("SWITCHROOM_WEDGE_FABLE_POLLS", DEFAULT_FABLE_CONSENT_POLLS);
646
707
  const permissionPromptSignature = opts.permissionPromptSignature === null ? null : opts.permissionPromptSignature ?? PERMISSION_PROMPT_SIGNATURE;
647
708
  const permissionPromptPolls = opts.permissionPromptPolls ?? envInt("SWITCHROOM_WEDGE_PERMISSION_POLLS", DEFAULT_PERMISSION_PROMPT_POLLS);
648
709
  const permissionCardlessPolls = Math.max(permissionPromptPolls, opts.permissionCardlessPolls ?? envInt("SWITCHROOM_WEDGE_PERMISSION_CARDLESS_POLLS", DEFAULT_PERMISSION_CARDLESS_POLLS));
@@ -664,6 +725,7 @@ async function runWedgeWatchdog(opts) {
664
725
  let rateLimitFires = 0;
665
726
  let overageCreditSelections = 0;
666
727
  let confirmModalFires = 0;
728
+ let fableConsentFires = 0;
667
729
  let permissionPromptFires = 0;
668
730
  let permissionPromptDeferrals = 0;
669
731
  let permissionPromptFloodHolds = 0;
@@ -671,10 +733,12 @@ async function runWedgeWatchdog(opts) {
671
733
  let restartEscalations = 0;
672
734
  let polls = 0;
673
735
  let confirmModalPresent = 0;
736
+ let fableConsentPresent = 0;
674
737
  let permissionPromptPresent = 0;
675
738
  let manifestStallPresent = 0;
676
739
  let lastManifestKey = null;
677
740
  let confirmCooldownUntil = 0;
741
+ let fableCooldownUntil = 0;
678
742
  let permissionCooldownUntil = 0;
679
743
  let lastPermissionHoldLogAt = Number.NEGATIVE_INFINITY;
680
744
  while (polls < maxPolls) {
@@ -687,9 +751,10 @@ async function runWedgeWatchdog(opts) {
687
751
  text = "";
688
752
  }
689
753
  const isRateLimitMenu = !!text && rateLimitSignature !== null && rateLimitSignature.test(text);
690
- const isPermissionPrompt = !isRateLimitMenu && !!text && permissionPromptSignature !== null && permissionPromptSignature.test(text);
691
- const isConfirmModal = !isRateLimitMenu && !isPermissionPrompt && !!text && confirmModalSignature !== null && confirmModalSignature.test(text);
692
- const isBlockingModal = !isRateLimitMenu && !isPermissionPrompt && !isConfirmModal && !!text && signature.test(text) && !deferToPrompts.some((p) => p.match.test(text));
754
+ const isFableConsent = !isRateLimitMenu && !!text && fableConsentSignature !== null && fableConsentSignature.test(text);
755
+ const isPermissionPrompt = !isRateLimitMenu && !isFableConsent && !!text && permissionPromptSignature !== null && permissionPromptSignature.test(text);
756
+ const isConfirmModal = !isRateLimitMenu && !isFableConsent && !isPermissionPrompt && !!text && confirmModalSignature !== null && confirmModalSignature.test(text);
757
+ const isBlockingModal = !isRateLimitMenu && !isFableConsent && !isPermissionPrompt && !isConfirmModal && !!text && signature.test(text) && !deferToPrompts.some((p) => ruleMatches2(p, text));
693
758
  const manifestSignatureHit = !!text && manifestStallSignature !== null && manifestStallSignature.test(text);
694
759
  const manifestKey = manifestSignatureHit ? stabilityKey(text) : null;
695
760
  const stopHookPresent = !!text && STOP_HOOK_ERROR_SIGNATURE.test(text);
@@ -767,10 +832,30 @@ async function runWedgeWatchdog(opts) {
767
832
  stableCount = 0;
768
833
  lastKey = null;
769
834
  }
835
+ } else if (isFableConsent) {
836
+ fableConsentPresent++;
837
+ stableCount = 0;
838
+ lastKey = null;
839
+ permissionPromptPresent = 0;
840
+ confirmModalPresent = 0;
841
+ if (fableConsentPresent >= fableConsentPolls && now() >= fableCooldownUntil) {
842
+ const nav = fableConsentNav2(text) ?? ["1", "Enter"];
843
+ console.error(`[wedge-watchdog] ${opts.agentName}: Fable-5 usage-credits consent modal present ` + `${fableConsentPresent} polls ` + `(~${Math.round(fableConsentPresent * pollIntervalMs / 1000)}s) \u2014 ` + `selecting "Continue with Fable 5" (${nav.join(" ")}); the session is on fable ` + `because the operator configured it, and Enter/Esc here would silently decline it`);
844
+ try {
845
+ send(opts.agentName, nav);
846
+ } catch (err) {
847
+ console.error(`[wedge-watchdog] ${opts.agentName}: send threw: ${err.message}`);
848
+ }
849
+ fires++;
850
+ fableConsentFires++;
851
+ fableCooldownUntil = now() + cooldownMs;
852
+ fableConsentPresent = 0;
853
+ }
770
854
  } else if (isPermissionPrompt) {
771
855
  permissionPromptPresent++;
772
856
  stableCount = 0;
773
857
  lastKey = null;
858
+ fableConsentPresent = 0;
774
859
  if (permissionPromptPresent >= permissionPromptPolls && now() >= permissionCooldownUntil) {
775
860
  let status = null;
776
861
  if (queryPendingPermission2) {
@@ -824,6 +909,7 @@ async function runWedgeWatchdog(opts) {
824
909
  stableCount = 0;
825
910
  lastKey = null;
826
911
  permissionPromptPresent = 0;
912
+ fableConsentPresent = 0;
827
913
  if (confirmModalPresent >= confirmModalPolls && now() >= confirmCooldownUntil) {
828
914
  console.error(`[wedge-watchdog] ${opts.agentName}: dismissing stuck confirmation modal ` + `(Esc == "No, go back") after ${confirmModalPresent} polls present ` + `(~${Math.round(confirmModalPresent * pollIntervalMs / 1000)}s, flicker-immune) ` + `\u2014 no human to answer it`);
829
915
  try {
@@ -861,6 +947,7 @@ async function runWedgeWatchdog(opts) {
861
947
  lastKey = null;
862
948
  confirmModalPresent = 0;
863
949
  permissionPromptPresent = 0;
950
+ fableConsentPresent = 0;
864
951
  }
865
952
  await sleep(pollIntervalMs);
866
953
  }
@@ -869,6 +956,7 @@ async function runWedgeWatchdog(opts) {
869
956
  rateLimitFires,
870
957
  overageCreditSelections,
871
958
  confirmModalFires,
959
+ fableConsentFires,
872
960
  permissionPromptFires,
873
961
  permissionPromptDeferrals,
874
962
  permissionPromptFloodHolds,
@@ -5628,7 +5716,7 @@ async function main() {
5628
5716
  queryPendingPermission: permissionCardAware ? undefined : null,
5629
5717
  floodPressure: permissionFloodAware ? undefined : null
5630
5718
  });
5631
- console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog returned reason=${res.reason} fires=${res.fires} rateLimitFires=${res.rateLimitFires} overageCreditSelections=${res.overageCreditSelections} confirmModalFires=${res.confirmModalFires} permissionPromptFires=${res.permissionPromptFires} permissionPromptDeferrals=${res.permissionPromptDeferrals} permissionPromptFloodHolds=${res.permissionPromptFloodHolds} permissionPromptCardlessHolds=${res.permissionPromptCardlessHolds} restartEscalations=${res.restartEscalations}`);
5719
+ console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog returned reason=${res.reason} fires=${res.fires} rateLimitFires=${res.rateLimitFires} overageCreditSelections=${res.overageCreditSelections} confirmModalFires=${res.confirmModalFires} fableConsentFires=${res.fableConsentFires} permissionPromptFires=${res.permissionPromptFires} permissionPromptDeferrals=${res.permissionPromptDeferrals} permissionPromptFloodHolds=${res.permissionPromptFloodHolds} permissionPromptCardlessHolds=${res.permissionPromptCardlessHolds} restartEscalations=${res.restartEscalations}`);
5632
5720
  } catch (err) {
5633
5721
  console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog unexpected throw: ${err.message}`);
5634
5722
  }
@@ -12499,7 +12499,7 @@ var AgentSchema = exports_external.object({
12499
12499
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
12500
12500
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
12501
12501
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
12502
- })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated worktree for each " + "repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main, backed by a " + "shared bare clone at ~/.switchroom/repos/<slug>.git. The worktree path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
12502
+ })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated standing tree for " + "each repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main \u2014 an " + "independent clone (own refs/stash, hardlinked objects) seeded from a shared bare " + "clone at ~/.switchroom/repos/<slug>.git. The tree's path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
12503
12503
  experimental: exports_external.object({
12504
12504
  legacy_pty: exports_external.boolean().optional().describe("Opt out of the default tmux supervisor (#725) and run the agent " + "under the legacy PTY supervisor instead. Default: false."),
12505
12505
  legacy_autoaccept_expect: exports_external.boolean().optional().describe("Opt the autoaccept gateway back into the legacy expect-script " + "behaviour instead of the tmux send-keys path. Default: false.")