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.
- package/bin/autoaccept.exp +21 -1
- package/bin/tmp-reaper.sh +234 -0
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +2 -2
- package/dist/cli/autoaccept-poll.js +95 -7
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +3432 -2751
- package/dist/host-control/main.js +177 -13
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +115 -0
- package/profiles/_shared/local-time.md.hbs +6 -0
- package/profiles/default/CLAUDE.md.hbs +0 -12
- package/telegram-plugin/bunfig.toml +12 -4
- package/telegram-plugin/dist/gateway/gateway.js +1017 -465
- package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
- package/telegram-plugin/gateway/approval-hold.ts +32 -1
- package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
- package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
- package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
- package/telegram-plugin/gateway/gateway.ts +12 -10
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
- package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
- package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
- package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
- package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
- package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
- package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +7 -1
- package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
- package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
- package/telegram-plugin/tests/parked-turn-start-preload.test.ts +65 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
- package/telegram-plugin/tests/queued-card-surface.test.ts +9 -1
- package/telegram-plugin/tests/stream-render-golden.test.ts +10 -1
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
- package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +7 -1
- package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +7 -1
package/bin/autoaccept.exp
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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} (${
|
|
125
|
-
sendKeys(opts.agentName,
|
|
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
|
|
691
|
-
const
|
|
692
|
-
const
|
|
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
|
|
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.")
|