switchroom 0.15.2 → 0.15.4
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/turn-pacing-hook.sh +112 -0
- package/bin/workspace-dynamic-hook.sh +105 -15
- package/bin/workspace-stable-hook.sh +2 -2
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +75 -12
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +1596 -1515
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +35 -2
- package/profiles/default/CLAUDE.md.hbs +13 -4
- package/telegram-plugin/dist/gateway/gateway.js +533 -33
- package/telegram-plugin/gateway/gateway.ts +152 -14
- package/telegram-plugin/gateway/inbound-spool.ts +107 -16
- package/telegram-plugin/gateway/model-command.ts +261 -7
- package/telegram-plugin/tests/inbound-spool.test.ts +101 -0
- package/telegram-plugin/tests/model-command.test.ts +179 -0
- package/telegram-plugin/tests/welcome-text.test.ts +11 -0
- package/telegram-plugin/uat/scenarios/jtbd-model-command-dm.test.ts +93 -0
- package/telegram-plugin/welcome-text.ts +16 -1
- package/profiles/default/workspace/HEARTBEAT.md.hbs +0 -40
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# UserPromptSubmit hook that emits the per-turn pacing directive.
|
|
3
|
+
#
|
|
4
|
+
# Wired into the agent's .claude/settings.json hooks.UserPromptSubmit by
|
|
5
|
+
# scaffold.ts when context_efficiency inject_on_change is enabled (the
|
|
6
|
+
# default). Replaces the inline `printf` that previously re-injected the
|
|
7
|
+
# static directive on every single turn, causing ~3 KB of redundant context
|
|
8
|
+
# per turn (measured ~290 KB over a 26-prompt session).
|
|
9
|
+
#
|
|
10
|
+
# Environment variables (set in the hook command by scaffold.ts):
|
|
11
|
+
#
|
|
12
|
+
# TURN_PACING_DIRECTIVE - The full directive text to inject. Scaffold.ts
|
|
13
|
+
# embeds the directive in the hook command string.
|
|
14
|
+
# TURN_PACING_HASH - sha256 of the directive text (hex). Used to
|
|
15
|
+
# detect when the directive changed between
|
|
16
|
+
# scaffold regenerations.
|
|
17
|
+
#
|
|
18
|
+
# Inject-on-change semantics: on every fire:
|
|
19
|
+
# 1. Read session_id from stdin JSON.
|
|
20
|
+
# 2. Check per-session state file under $TELEGRAM_STATE_DIR/.hook-state/.
|
|
21
|
+
# 3. If session matches AND directive hash matches recorded state → emit
|
|
22
|
+
# nothing (the model already has the directive in context).
|
|
23
|
+
# 4. Otherwise emit the directive and record the new state.
|
|
24
|
+
#
|
|
25
|
+
# Fail open: any error in state management falls back to emitting the full
|
|
26
|
+
# directive so context is never silently dropped.
|
|
27
|
+
#
|
|
28
|
+
# State files: $TELEGRAM_STATE_DIR/.hook-state/turn-pacing.<session_id>
|
|
29
|
+
# Each file contains: <directive_hash>\n
|
|
30
|
+
# Stale state files (different session_id) are cleaned up on each new-
|
|
31
|
+
# session write (keeps at most 5 files to avoid accumulation).
|
|
32
|
+
|
|
33
|
+
set -u
|
|
34
|
+
|
|
35
|
+
DIRECTIVE="${TURN_PACING_DIRECTIVE:-}"
|
|
36
|
+
DIRECTIVE_HASH="${TURN_PACING_HASH:-}"
|
|
37
|
+
|
|
38
|
+
# If directive or hash are missing, emit whatever we have and exit.
|
|
39
|
+
if [ -z "$DIRECTIVE" ]; then
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
emit_full() {
|
|
44
|
+
printf '%s\n' "$DIRECTIVE"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Read stdin JSON to extract session_id.
|
|
48
|
+
SESSION_ID=""
|
|
49
|
+
if ! [ -t 0 ]; then
|
|
50
|
+
STDIN_JSON=$(cat 2>/dev/null || true)
|
|
51
|
+
if command -v jq >/dev/null 2>&1; then
|
|
52
|
+
SESSION_ID=$(printf '%s' "$STDIN_JSON" | jq -r '.session_id // empty' 2>/dev/null || true)
|
|
53
|
+
else
|
|
54
|
+
SESSION_ID=$(printf '%s' "$STDIN_JSON" | grep -o '"session_id":"[^"]*"' | sed 's/"session_id":"//;s/"//' 2>/dev/null || true)
|
|
55
|
+
fi
|
|
56
|
+
# Sanitise: session_id must be alphanumeric + hyphens only.
|
|
57
|
+
if ! printf '%s' "${SESSION_ID:-}" | grep -qE '^[a-zA-Z0-9_-]{1,128}$' 2>/dev/null; then
|
|
58
|
+
SESSION_ID=""
|
|
59
|
+
fi
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
# If we can't get a session_id, emit full (fail-open).
|
|
63
|
+
if [ -z "$SESSION_ID" ]; then
|
|
64
|
+
emit_full
|
|
65
|
+
exit 0
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
# If hash is missing, emit full (fail-open).
|
|
69
|
+
if [ -z "$DIRECTIVE_HASH" ]; then
|
|
70
|
+
emit_full
|
|
71
|
+
exit 0
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
# Locate state dir. Prefer $TELEGRAM_STATE_DIR (set by start.sh in the
|
|
75
|
+
# container); fall back to $HOME/.claude/switchroom-hookcache for dev use.
|
|
76
|
+
STATE_BASE="${TELEGRAM_STATE_DIR:-${HOME:-/tmp}/.claude/switchroom-hookcache}"
|
|
77
|
+
HOOK_STATE_DIR="$STATE_BASE/.hook-state"
|
|
78
|
+
|
|
79
|
+
# Attempt to create the state dir; if that fails emit full (fail-open).
|
|
80
|
+
if ! mkdir -p "$HOOK_STATE_DIR" 2>/dev/null; then
|
|
81
|
+
emit_full
|
|
82
|
+
exit 0
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
STATE_FILE="$HOOK_STATE_DIR/turn-pacing.$SESSION_ID"
|
|
86
|
+
|
|
87
|
+
# Check if already emitted for this session with the same directive.
|
|
88
|
+
if [ -f "$STATE_FILE" ]; then
|
|
89
|
+
RECORDED=$(head -1 "$STATE_FILE" 2>/dev/null || echo "")
|
|
90
|
+
if [ "$RECORDED" = "$DIRECTIVE_HASH" ]; then
|
|
91
|
+
# Already injected and content unchanged — suppress.
|
|
92
|
+
exit 0
|
|
93
|
+
fi
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# New session or directive changed — emit and record state.
|
|
97
|
+
emit_full
|
|
98
|
+
|
|
99
|
+
# Write state: hash on first line.
|
|
100
|
+
printf '%s\n' "$DIRECTIVE_HASH" > "$STATE_FILE" 2>/dev/null || true
|
|
101
|
+
|
|
102
|
+
# Clean up stale state files (different session_id) — keep at most 5 files.
|
|
103
|
+
# shellcheck disable=SC2012
|
|
104
|
+
OLD_COUNT=$(ls -1 "$HOOK_STATE_DIR"/turn-pacing.* 2>/dev/null | grep -cv "turn-pacing\.$SESSION_ID$" || true)
|
|
105
|
+
if [ "$OLD_COUNT" -gt 5 ]; then
|
|
106
|
+
ls -1t "$HOOK_STATE_DIR"/turn-pacing.* 2>/dev/null \
|
|
107
|
+
| grep -v "turn-pacing\.$SESSION_ID$" \
|
|
108
|
+
| tail -n +6 \
|
|
109
|
+
| xargs rm -f 2>/dev/null || true
|
|
110
|
+
fi
|
|
111
|
+
|
|
112
|
+
exit 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# UserPromptSubmit hook for dynamic workspace bootstrap (MEMORY.md, daily
|
|
3
|
-
# notes
|
|
3
|
+
# notes).
|
|
4
4
|
#
|
|
5
5
|
# Wired into the agent's .claude/settings.json hooks.UserPromptSubmit by
|
|
6
6
|
# scaffold.ts. On every inbound user prompt, this script re-renders the
|
|
@@ -9,17 +9,27 @@
|
|
|
9
9
|
#
|
|
10
10
|
# Configuration is via env vars (set at start.sh time):
|
|
11
11
|
#
|
|
12
|
-
# SWITCHROOM_AGENT_NAME
|
|
12
|
+
# SWITCHROOM_AGENT_NAME - The agent name (required, set in start.sh)
|
|
13
|
+
# SWITCHROOM_INJECT_ON_CHANGE - When "1", enables inject-on-change
|
|
14
|
+
# semantics: content is only emitted when the
|
|
15
|
+
# session_id changes or file content changes.
|
|
16
|
+
# When "0" or unset (legacy mode), content is
|
|
17
|
+
# emitted every turn as before (full emit).
|
|
18
|
+
# Default in new agents is "1" (set by
|
|
19
|
+
# scaffold.ts when inject_on_change is true,
|
|
20
|
+
# the default).
|
|
13
21
|
#
|
|
14
22
|
# Failure modes (all silent — workspace injection must never block the turn):
|
|
15
23
|
# - switchroom CLI missing → exit 0 with no output
|
|
16
24
|
# - workspace dir missing → exit 0 with no output
|
|
17
25
|
# - workspace render fails → exit 0 with no output
|
|
18
26
|
# - empty result set → exit 0 with no output
|
|
27
|
+
# - state dir error → emit full content (fail-open)
|
|
19
28
|
|
|
20
29
|
set -u
|
|
21
30
|
|
|
22
31
|
AGENT_NAME="${SWITCHROOM_AGENT_NAME:-}"
|
|
32
|
+
INJECT_ON_CHANGE="${SWITCHROOM_INJECT_ON_CHANGE:-0}"
|
|
23
33
|
|
|
24
34
|
if [ -z "$AGENT_NAME" ]; then
|
|
25
35
|
exit 0
|
|
@@ -29,6 +39,26 @@ if ! command -v switchroom >/dev/null 2>&1; then
|
|
|
29
39
|
exit 0
|
|
30
40
|
fi
|
|
31
41
|
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Inject-on-change: read session_id from stdin JSON (when enabled)
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
SESSION_ID=""
|
|
46
|
+
if [ "$INJECT_ON_CHANGE" = "1" ]; then
|
|
47
|
+
# Read stdin (non-blocking: if no stdin supplied in tests, just skip).
|
|
48
|
+
if ! [ -t 0 ]; then
|
|
49
|
+
STDIN_JSON=$(cat 2>/dev/null || true)
|
|
50
|
+
if command -v jq >/dev/null 2>&1; then
|
|
51
|
+
SESSION_ID=$(printf '%s' "$STDIN_JSON" | jq -r '.session_id // empty' 2>/dev/null || true)
|
|
52
|
+
else
|
|
53
|
+
SESSION_ID=$(printf '%s' "$STDIN_JSON" | grep -o '"session_id":"[^"]*"' | sed 's/"session_id":"//;s/"//' 2>/dev/null || true)
|
|
54
|
+
fi
|
|
55
|
+
# Sanitise session_id — must be alphanumeric + hyphens only.
|
|
56
|
+
if ! printf '%s' "${SESSION_ID:-}" | grep -qE '^[a-zA-Z0-9_-]{1,128}$' 2>/dev/null; then
|
|
57
|
+
SESSION_ID=""
|
|
58
|
+
fi
|
|
59
|
+
fi
|
|
60
|
+
fi
|
|
61
|
+
|
|
32
62
|
# Cache directory shared with the post-render dedupe sidecar below. We
|
|
33
63
|
# need it earlier than the original code so the mtime-based fast-skip
|
|
34
64
|
# can read its body file before invoking the (~800ms) switchroom CLI.
|
|
@@ -46,15 +76,15 @@ BODY_FILE="$CACHE_DIR/workspace-dynamic.${CACHE_DATE}.body"
|
|
|
46
76
|
# Mtime-fast-skip: if BODY_FILE exists AND is newer than every workspace
|
|
47
77
|
# source the renderer reads, we can emit the cached body and skip the
|
|
48
78
|
# ~800ms `switchroom workspace render` invocation entirely. The renderer
|
|
49
|
-
# reads MEMORY.md,
|
|
79
|
+
# reads MEMORY.md, today's daily, yesterday's daily — see
|
|
50
80
|
# `loadDynamicBootstrapFiles` in src/agents/workspace.ts.
|
|
51
81
|
#
|
|
52
82
|
# Skip semantics: a source file that doesn't exist contributes a "very
|
|
53
83
|
# old" mtime (epoch-0 via stat fallback), which never invalidates the
|
|
54
84
|
# cache. A source file that's been updated since BODY_FILE's mtime
|
|
55
85
|
# triggers a fresh render. Forensics measured this fast-path saving
|
|
56
|
-
# ~825ms on the common case (chat turns where MEMORY
|
|
57
|
-
#
|
|
86
|
+
# ~825ms on the common case (chat turns where MEMORY hasn't changed
|
|
87
|
+
# since the last turn).
|
|
58
88
|
#
|
|
59
89
|
# Resolve the agent's workspace dir. Switchroom uses
|
|
60
90
|
# `~/.switchroom/agents/<name>/workspace/` by default. We avoid invoking
|
|
@@ -75,7 +105,7 @@ if [ -f "$BODY_FILE" ]; then
|
|
|
75
105
|
# missing), emit the cache and exit.
|
|
76
106
|
body_mtime=$(stat -c '%Y' "$BODY_FILE" 2>/dev/null || echo 0)
|
|
77
107
|
newest_src_mtime=0
|
|
78
|
-
for src in "$WS_DIR/MEMORY.md" "$
|
|
108
|
+
for src in "$WS_DIR/MEMORY.md" "$TODAY_FILE" "$YESTERDAY_FILE"; do
|
|
79
109
|
if [ -f "$src" ]; then
|
|
80
110
|
src_mtime=$(stat -c '%Y' "$src" 2>/dev/null || echo 0)
|
|
81
111
|
if [ "$src_mtime" -gt "$newest_src_mtime" ]; then
|
|
@@ -84,18 +114,38 @@ if [ -f "$BODY_FILE" ]; then
|
|
|
84
114
|
fi
|
|
85
115
|
done
|
|
86
116
|
if [ "$body_mtime" -gt "$newest_src_mtime" ]; then
|
|
87
|
-
# Fast path: every source is older than the cached body.
|
|
88
|
-
#
|
|
89
|
-
#
|
|
117
|
+
# Fast path: every source is older than the cached body.
|
|
118
|
+
# In inject-on-change mode, also check the session-state file — if the
|
|
119
|
+
# session_id matches the last-emitted session AND the hash matches, we
|
|
120
|
+
# can suppress entirely (model already has this content in context).
|
|
121
|
+
if [ "$INJECT_ON_CHANGE" = "1" ] && [ -n "$SESSION_ID" ]; then
|
|
122
|
+
SESSION_STATE_DIR="${TELEGRAM_STATE_DIR:-${HOME:-/tmp}/.claude/switchroom-hookcache}/.hook-state"
|
|
123
|
+
SESSION_STATE_FILE="$SESSION_STATE_DIR/ws-dynamic.$SESSION_ID"
|
|
124
|
+
if [ -f "$SESSION_STATE_FILE" ]; then
|
|
125
|
+
RECORDED_HASH=$(head -1 "$SESSION_STATE_FILE" 2>/dev/null || echo "")
|
|
126
|
+
# Read hash from body cache to compare with session state.
|
|
127
|
+
CACHED_HASH=$(head -1 "$CACHE_FILE" 2>/dev/null || echo "")
|
|
128
|
+
if [ -n "$CACHED_HASH" ] && [ "$CACHED_HASH" = "$RECORDED_HASH" ]; then
|
|
129
|
+
# Same session, same content — suppress injection.
|
|
130
|
+
exit 0
|
|
131
|
+
fi
|
|
132
|
+
fi
|
|
133
|
+
# Different session or hash changed — emit, then update session state.
|
|
134
|
+
mkdir -p "$SESSION_STATE_DIR" 2>/dev/null || true
|
|
135
|
+
CACHED_HASH_FOR_STATE=$(head -1 "$CACHE_FILE" 2>/dev/null || echo "")
|
|
136
|
+
if [ -n "$CACHED_HASH_FOR_STATE" ]; then
|
|
137
|
+
printf '%s\n%s\n' "$CACHED_HASH_FOR_STATE" "$SESSION_ID" > "$SESSION_STATE_FILE" 2>/dev/null || true
|
|
138
|
+
fi
|
|
139
|
+
fi
|
|
90
140
|
cat "$BODY_FILE"
|
|
91
141
|
exit 0
|
|
92
142
|
fi
|
|
93
143
|
fi
|
|
94
144
|
|
|
95
|
-
# Render the dynamic workspace files (MEMORY.md, today/yesterday daily
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
145
|
+
# Render the dynamic workspace files (MEMORY.md, today/yesterday daily).
|
|
146
|
+
# The render command exits 0 and returns empty string if the workspace
|
|
147
|
+
# doesn't exist or all dynamic files are missing/empty, so no special-casing
|
|
148
|
+
# needed here.
|
|
99
149
|
#
|
|
100
150
|
# --warning-mode off: truncation warnings go to the stable render (where they
|
|
101
151
|
# can surface during scaffold/reconcile), not the per-turn path where they'd
|
|
@@ -107,8 +157,8 @@ fi
|
|
|
107
157
|
WS_DYNAMIC=$(timeout 3 switchroom workspace render "$AGENT_NAME" --dynamic --warning-mode off 2>/dev/null || true)
|
|
108
158
|
|
|
109
159
|
# Empty render → emit nothing AND do NOT cache the empty body. Caching an
|
|
110
|
-
# empty body would re-emit empty forever even after MEMORY
|
|
111
|
-
#
|
|
160
|
+
# empty body would re-emit empty forever even after MEMORY comes back online,
|
|
161
|
+
# defeating the whole purpose of the hook.
|
|
112
162
|
if [ -z "$WS_DYNAMIC" ]; then
|
|
113
163
|
exit 0
|
|
114
164
|
fi
|
|
@@ -126,7 +176,44 @@ if [ -f "$CACHE_FILE" ]; then
|
|
|
126
176
|
OLD_HASH=$(head -1 "$CACHE_FILE" 2>/dev/null || echo "")
|
|
127
177
|
fi
|
|
128
178
|
|
|
179
|
+
# Session-state helper (shared by both the hash-match and hash-changed paths).
|
|
180
|
+
_ws_record_session_state() {
|
|
181
|
+
local hash="$1" sid="$2"
|
|
182
|
+
if [ -n "$hash" ] && [ -n "$sid" ]; then
|
|
183
|
+
local sdir="${TELEGRAM_STATE_DIR:-${HOME:-/tmp}/.claude/switchroom-hookcache}/.hook-state"
|
|
184
|
+
[ -d "$sdir" ] || mkdir -p "$sdir" 2>/dev/null || true
|
|
185
|
+
if [ -d "$sdir" ]; then
|
|
186
|
+
printf '%s\n%s\n' "$hash" "$sid" > "$sdir/ws-dynamic.$sid" 2>/dev/null || true
|
|
187
|
+
# Keep at most 5 ws-dynamic state files to bound growth.
|
|
188
|
+
# shellcheck disable=SC2012
|
|
189
|
+
local old_count
|
|
190
|
+
old_count=$(ls -1 "$sdir"/ws-dynamic.* 2>/dev/null | grep -cv "ws-dynamic\.$sid$" || true)
|
|
191
|
+
if [ "$old_count" -gt 5 ]; then
|
|
192
|
+
ls -1t "$sdir"/ws-dynamic.* 2>/dev/null \
|
|
193
|
+
| grep -v "ws-dynamic\.$sid$" \
|
|
194
|
+
| tail -n +6 \
|
|
195
|
+
| xargs rm -f 2>/dev/null || true
|
|
196
|
+
fi
|
|
197
|
+
fi
|
|
198
|
+
fi
|
|
199
|
+
}
|
|
200
|
+
|
|
129
201
|
if [ -n "$NEW_HASH" ] && [ "$NEW_HASH" = "$OLD_HASH" ] && [ -f "$BODY_FILE" ]; then
|
|
202
|
+
# Content unchanged since last render. In inject-on-change mode, check if
|
|
203
|
+
# we already injected this content in the current session — if so, suppress.
|
|
204
|
+
if [ "$INJECT_ON_CHANGE" = "1" ] && [ -n "$SESSION_ID" ]; then
|
|
205
|
+
SESSION_STATE_DIR="${TELEGRAM_STATE_DIR:-${HOME:-/tmp}/.claude/switchroom-hookcache}/.hook-state"
|
|
206
|
+
SESSION_STATE_FILE="$SESSION_STATE_DIR/ws-dynamic.$SESSION_ID"
|
|
207
|
+
if [ -f "$SESSION_STATE_FILE" ]; then
|
|
208
|
+
RECORDED_HASH=$(head -1 "$SESSION_STATE_FILE" 2>/dev/null || echo "")
|
|
209
|
+
if [ "$RECORDED_HASH" = "$NEW_HASH" ]; then
|
|
210
|
+
# Same session, same content — suppress injection.
|
|
211
|
+
exit 0
|
|
212
|
+
fi
|
|
213
|
+
fi
|
|
214
|
+
# New session or session state mismatch — emit and record.
|
|
215
|
+
_ws_record_session_state "$NEW_HASH" "$SESSION_ID"
|
|
216
|
+
fi
|
|
130
217
|
cat "$BODY_FILE"
|
|
131
218
|
else
|
|
132
219
|
# Refresh sidecar: write hash + body, then echo body. Touch the body
|
|
@@ -135,6 +222,9 @@ else
|
|
|
135
222
|
if [ -n "$NEW_HASH" ]; then
|
|
136
223
|
printf '%s\n' "$NEW_HASH" > "$CACHE_FILE" 2>/dev/null || true
|
|
137
224
|
printf '%s\n' "$WS_DYNAMIC" > "$BODY_FILE" 2>/dev/null || true
|
|
225
|
+
if [ "$INJECT_ON_CHANGE" = "1" ] && [ -n "$SESSION_ID" ]; then
|
|
226
|
+
_ws_record_session_state "$NEW_HASH" "$SESSION_ID"
|
|
227
|
+
fi
|
|
138
228
|
fi
|
|
139
229
|
printf '%s\n' "$WS_DYNAMIC"
|
|
140
230
|
fi
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# UserPromptSubmit hook for stable workspace content (AGENTS.md, SOUL.md,
|
|
3
|
-
# USER.md, IDENTITY.md, TOOLS.md
|
|
3
|
+
# USER.md, IDENTITY.md, TOOLS.md).
|
|
4
4
|
#
|
|
5
5
|
# Used only when channels.telegram.hotReloadStable is true. In that mode,
|
|
6
6
|
# the stable workspace render moves from a session-start bake into
|
|
@@ -34,7 +34,7 @@ if ! command -v switchroom >/dev/null 2>&1; then
|
|
|
34
34
|
fi
|
|
35
35
|
|
|
36
36
|
# Render the stable workspace files (AGENTS.md, SOUL.md, IDENTITY.md, USER.md,
|
|
37
|
-
# TOOLS.md
|
|
37
|
+
# TOOLS.md). The render command exits 0 and returns empty string
|
|
38
38
|
# if the workspace doesn't exist or all stable files are missing/empty, so no
|
|
39
39
|
# special-casing needed here.
|
|
40
40
|
#
|
|
@@ -11090,7 +11090,8 @@ var TelegramChannelSchema = exports_external.object({
|
|
|
11090
11090
|
stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI — " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events — stable " + "order, per-tool status emojis, fires only on semantic transitions."),
|
|
11091
11091
|
stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive stream edits (or " + "sendMessageDraft tics) during a turn. Lower = more responsive " + "stream, higher = fewer API calls. Floored at 250 by draft-stream " + "itself. Default 300 for draft transport (DMs) and 1000 for " + "message transport (groups/forums). Override per-agent if a " + "particular agent needs snappier or quieter streaming."),
|
|
11092
11092
|
clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message — Reading X, Searching the web for Y, …) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) — no post-then-delete. Per-agent " + "override; cascades defaults → profile → agent (per-key)."),
|
|
11093
|
-
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md
|
|
11093
|
+
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md) is re-injected on every turn via " + "the UserPromptSubmit hook instead of baked into --append-system-prompt " + "at session start. Lets workspace edits propagate without a restart. " + "Costs ~5-10% per-turn latency/spend since the stable prefix is no " + "longer prompt-cached."),
|
|
11094
|
+
inject_on_change: exports_external.boolean().optional().describe("Context-efficiency gate for per-turn hook injection (default true). " + "When true (the default), the turn-pacing directive and dynamic " + "workspace content are only re-emitted when their content changes or " + "the session_id changes — suppressing redundant injection that " + "otherwise triples compaction frequency. Set to false to revert to " + "the legacy always-emit behaviour (every turn injects the full " + "content regardless of whether it changed)."),
|
|
11094
11095
|
orphan_promotion_ms: exports_external.number().int().nonnegative().optional().describe("How long (ms) a parent turn waits for a sub-agent JSONL watcher " + "to deliver sub_agent_started before the heartbeat promotes the spawn " + "to a synthesised 'running' row. Default 5000. Set to 0 to disable " + "orphan promotion entirely."),
|
|
11095
11096
|
cold_sub_agent_threshold_ms: exports_external.number().int().nonnegative().optional().describe("JSONL-cold threshold (ms). When a running sub-agent emits no events " + "for this long, the heartbeat synthesises a turn_end for it so the " + "deferred-completion path can proceed. Default 30000. Set to 0 to " + "disable the synthetic close."),
|
|
11096
11097
|
deferred_completion_timeout_ms: exports_external.number().int().nonnegative().optional().describe("Force-close timeout (ms) for deferred sub-agent completion. After " + "the parent turn_end arrives while sub-agents are still running, the " + "card is force-closed after this many ms even if sub-agents never " + "finish. Watcher-disconnect safety net. Default 180000 (3 min)."),
|
|
@@ -11090,7 +11090,8 @@ var TelegramChannelSchema = exports_external.object({
|
|
|
11090
11090
|
stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI — " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events — stable " + "order, per-tool status emojis, fires only on semantic transitions."),
|
|
11091
11091
|
stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive stream edits (or " + "sendMessageDraft tics) during a turn. Lower = more responsive " + "stream, higher = fewer API calls. Floored at 250 by draft-stream " + "itself. Default 300 for draft transport (DMs) and 1000 for " + "message transport (groups/forums). Override per-agent if a " + "particular agent needs snappier or quieter streaming."),
|
|
11092
11092
|
clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message — Reading X, Searching the web for Y, …) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) — no post-then-delete. Per-agent " + "override; cascades defaults → profile → agent (per-key)."),
|
|
11093
|
-
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md
|
|
11093
|
+
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md) is re-injected on every turn via " + "the UserPromptSubmit hook instead of baked into --append-system-prompt " + "at session start. Lets workspace edits propagate without a restart. " + "Costs ~5-10% per-turn latency/spend since the stable prefix is no " + "longer prompt-cached."),
|
|
11094
|
+
inject_on_change: exports_external.boolean().optional().describe("Context-efficiency gate for per-turn hook injection (default true). " + "When true (the default), the turn-pacing directive and dynamic " + "workspace content are only re-emitted when their content changes or " + "the session_id changes — suppressing redundant injection that " + "otherwise triples compaction frequency. Set to false to revert to " + "the legacy always-emit behaviour (every turn injects the full " + "content regardless of whether it changed)."),
|
|
11094
11095
|
orphan_promotion_ms: exports_external.number().int().nonnegative().optional().describe("How long (ms) a parent turn waits for a sub-agent JSONL watcher " + "to deliver sub_agent_started before the heartbeat promotes the spawn " + "to a synthesised 'running' row. Default 5000. Set to 0 to disable " + "orphan promotion entirely."),
|
|
11095
11096
|
cold_sub_agent_threshold_ms: exports_external.number().int().nonnegative().optional().describe("JSONL-cold threshold (ms). When a running sub-agent emits no events " + "for this long, the heartbeat synthesises a turn_end for it so the " + "deferred-completion path can proceed. Default 30000. Set to 0 to " + "disable the synthetic close."),
|
|
11096
11097
|
deferred_completion_timeout_ms: exports_external.number().int().nonnegative().optional().describe("Force-close timeout (ms) for deferred sub-agent completion. After " + "the parent turn_end arrives while sub-agents are still running, the " + "card is force-closed after this many ms even if sub-agents never " + "finish. Watcher-disconnect safety net. Default 180000 (3 min)."),
|
|
@@ -12363,6 +12364,47 @@ function resolveConsumerProbeIntervalMs(env) {
|
|
|
12363
12364
|
return DEFAULT_CONSUMER_PROBE_INTERVAL_MS;
|
|
12364
12365
|
}
|
|
12365
12366
|
|
|
12367
|
+
// src/auth/broker/account-eligibility.ts
|
|
12368
|
+
var WALL_PCT = 99.5;
|
|
12369
|
+
var HEALTHY_CLEAR_PCT = 80;
|
|
12370
|
+
var SNAPSHOT_STALE_AGE_MS = 24 * 60 * 60 * 1000;
|
|
12371
|
+
function snapshotFresh(s, now, maxAgeMs = SNAPSHOT_STALE_AGE_MS) {
|
|
12372
|
+
return !!s && now - s.capturedAt <= maxAgeMs && s.capturedAt <= now + 60000;
|
|
12373
|
+
}
|
|
12374
|
+
function snapshotWalled(s) {
|
|
12375
|
+
return s.fiveHourUtilizationPct >= WALL_PCT || s.sevenDayUtilizationPct >= WALL_PCT;
|
|
12376
|
+
}
|
|
12377
|
+
function snapshotClearlyHealthy(s) {
|
|
12378
|
+
return s.fiveHourUtilizationPct < HEALTHY_CLEAR_PCT && s.sevenDayUtilizationPct < HEALTHY_CLEAR_PCT;
|
|
12379
|
+
}
|
|
12380
|
+
function isAccountBlocked(opts) {
|
|
12381
|
+
const { mark, snapshot, now } = opts;
|
|
12382
|
+
if (snapshotFresh(snapshot, now)) {
|
|
12383
|
+
const markedAt = mark?.marked_at ?? 0;
|
|
12384
|
+
if (snapshot.capturedAt >= markedAt) {
|
|
12385
|
+
return snapshotWalled(snapshot);
|
|
12386
|
+
}
|
|
12387
|
+
}
|
|
12388
|
+
return mark !== undefined && mark.exhausted_until > now;
|
|
12389
|
+
}
|
|
12390
|
+
function snapshotShouldClearMark(snapshot, mark, now) {
|
|
12391
|
+
if (!mark)
|
|
12392
|
+
return false;
|
|
12393
|
+
if (!snapshotFresh(snapshot, now))
|
|
12394
|
+
return false;
|
|
12395
|
+
if (snapshot.capturedAt < (mark.marked_at ?? 0))
|
|
12396
|
+
return false;
|
|
12397
|
+
return snapshotClearlyHealthy(snapshot);
|
|
12398
|
+
}
|
|
12399
|
+
function clampMarkExpiry(opts) {
|
|
12400
|
+
const { proposedUntil, now, shortMs, snapshot } = opts;
|
|
12401
|
+
const shortCeil = now + shortMs;
|
|
12402
|
+
if (proposedUntil <= shortCeil)
|
|
12403
|
+
return proposedUntil;
|
|
12404
|
+
const liveContradictsWeeklyWall = snapshotFresh(snapshot, now) && snapshot.sevenDayUtilizationPct < WALL_PCT;
|
|
12405
|
+
return liveContradictsWeeklyWall ? shortCeil : proposedUntil;
|
|
12406
|
+
}
|
|
12407
|
+
|
|
12366
12408
|
// src/util/atomic.ts
|
|
12367
12409
|
import { randomBytes } from "node:crypto";
|
|
12368
12410
|
import { closeSync, constants, fsyncSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
@@ -14030,8 +14072,11 @@ class AuthBroker {
|
|
|
14030
14072
|
return this.accountWithFailover(account);
|
|
14031
14073
|
}
|
|
14032
14074
|
isAccountExhausted(account) {
|
|
14033
|
-
|
|
14034
|
-
|
|
14075
|
+
return isAccountBlocked({
|
|
14076
|
+
mark: this.quota[account],
|
|
14077
|
+
snapshot: this.lastQuotaCache[account],
|
|
14078
|
+
now: this.now()
|
|
14079
|
+
});
|
|
14035
14080
|
}
|
|
14036
14081
|
accountWithFailover(account) {
|
|
14037
14082
|
if (!account || !this.isAccountExhausted(account))
|
|
@@ -14067,7 +14112,7 @@ class AuthBroker {
|
|
|
14067
14112
|
const creds = readAccountCredentials(label, this.home);
|
|
14068
14113
|
const meta = readAccountMeta(label, this.home);
|
|
14069
14114
|
const q = this.quota[label];
|
|
14070
|
-
const exhausted =
|
|
14115
|
+
const exhausted = this.isAccountExhausted(label);
|
|
14071
14116
|
const lq = this.lastQuotaCache[label];
|
|
14072
14117
|
return {
|
|
14073
14118
|
label,
|
|
@@ -14142,7 +14187,7 @@ class AuthBroker {
|
|
|
14142
14187
|
cacheQuotaSnapshot(label, result) {
|
|
14143
14188
|
if (!result.ok)
|
|
14144
14189
|
return;
|
|
14145
|
-
|
|
14190
|
+
const snapshot = {
|
|
14146
14191
|
fiveHourUtilizationPct: result.data.fiveHourUtilizationPct,
|
|
14147
14192
|
sevenDayUtilizationPct: result.data.sevenDayUtilizationPct,
|
|
14148
14193
|
fiveHourResetAt: result.data.fiveHourResetAt?.toISOString() ?? null,
|
|
@@ -14152,6 +14197,13 @@ class AuthBroker {
|
|
|
14152
14197
|
overageDisabledReason: result.data.overageDisabledReason,
|
|
14153
14198
|
capturedAt: this.now()
|
|
14154
14199
|
};
|
|
14200
|
+
this.lastQuotaCache[label] = snapshot;
|
|
14201
|
+
if (snapshotShouldClearMark(snapshot, this.quota[label], this.now())) {
|
|
14202
|
+
delete this.quota[label];
|
|
14203
|
+
this.persistQuota();
|
|
14204
|
+
process.stdout.write(`auth-broker: live probe shows ${label} healthy (5h=${snapshot.fiveHourUtilizationPct}% 7d=${snapshot.sevenDayUtilizationPct}%) — cleared stale exhaustion mark
|
|
14205
|
+
`);
|
|
14206
|
+
}
|
|
14155
14207
|
}
|
|
14156
14208
|
async fleetQuotaProbeTick() {
|
|
14157
14209
|
for (const label of listAccounts(this.home)) {
|
|
@@ -14183,14 +14235,21 @@ class AuthBroker {
|
|
|
14183
14235
|
this.logErr(`consumer-quota-probe ${label}: ${err.message}`);
|
|
14184
14236
|
continue;
|
|
14185
14237
|
}
|
|
14238
|
+
this.cacheQuotaSnapshot(label, result);
|
|
14186
14239
|
const decision = quotaIndicatesExhaustion(result);
|
|
14187
14240
|
if (!decision.exhausted)
|
|
14188
14241
|
continue;
|
|
14189
|
-
const
|
|
14242
|
+
const now = this.now();
|
|
14243
|
+
const exhaustedUntil = clampMarkExpiry({
|
|
14244
|
+
proposedUntil: decision.until ?? now + MARK_EXHAUSTED_DEFAULT_MS,
|
|
14245
|
+
now,
|
|
14246
|
+
shortMs: MARK_EXHAUSTED_DEFAULT_MS,
|
|
14247
|
+
snapshot: this.lastQuotaCache[label]
|
|
14248
|
+
});
|
|
14190
14249
|
const existing = this.quota[label]?.exhausted_until;
|
|
14191
14250
|
if (existing !== undefined && existing >= exhaustedUntil)
|
|
14192
14251
|
continue;
|
|
14193
|
-
this.quota[label] = { exhausted_until: exhaustedUntil };
|
|
14252
|
+
this.quota[label] = { exhausted_until: exhaustedUntil, marked_at: now };
|
|
14194
14253
|
this.persistQuota();
|
|
14195
14254
|
this.audit({ op: "mark-exhausted", identity: { kind: "operator" }, account: label, ok: true });
|
|
14196
14255
|
process.stdout.write(`auth-broker: consumer-quota-sensor marked ${label} exhausted until ${new Date(exhaustedUntil).toISOString()} — consumer(s) fail over
|
|
@@ -14224,8 +14283,14 @@ class AuthBroker {
|
|
|
14224
14283
|
socket.write(encodeError(id, "ACCOUNT_NOT_FOUND", "no active account configured"));
|
|
14225
14284
|
return;
|
|
14226
14285
|
}
|
|
14227
|
-
const
|
|
14228
|
-
|
|
14286
|
+
const now = this.now();
|
|
14287
|
+
const exhaustedUntil = clampMarkExpiry({
|
|
14288
|
+
proposedUntil: until ?? now + MARK_EXHAUSTED_DEFAULT_MS,
|
|
14289
|
+
now,
|
|
14290
|
+
shortMs: MARK_EXHAUSTED_DEFAULT_MS,
|
|
14291
|
+
snapshot: this.lastQuotaCache[account]
|
|
14292
|
+
});
|
|
14293
|
+
this.quota[account] = { exhausted_until: exhaustedUntil, marked_at: now };
|
|
14229
14294
|
this.persistQuota();
|
|
14230
14295
|
const rolled = this.fanoutFailoverFor(account);
|
|
14231
14296
|
const rolledTo = this.nextHealthyAccount(account, this.config.auth?.fallback_order ?? []);
|
|
@@ -14752,9 +14817,7 @@ class AuthBroker {
|
|
|
14752
14817
|
const cand = order[(start + i) % order.length];
|
|
14753
14818
|
if (!cand)
|
|
14754
14819
|
continue;
|
|
14755
|
-
|
|
14756
|
-
const exhausted = q !== undefined && q.exhausted_until > this.now();
|
|
14757
|
-
if (!exhausted && accountExists(cand, this.home))
|
|
14820
|
+
if (!this.isAccountExhausted(cand) && accountExists(cand, this.home))
|
|
14758
14821
|
return cand;
|
|
14759
14822
|
}
|
|
14760
14823
|
return null;
|
|
@@ -11838,7 +11838,8 @@ var TelegramChannelSchema = exports_external.object({
|
|
|
11838
11838
|
stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI \u2014 " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events \u2014 stable " + "order, per-tool status emojis, fires only on semantic transitions."),
|
|
11839
11839
|
stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive stream edits (or " + "sendMessageDraft tics) during a turn. Lower = more responsive " + "stream, higher = fewer API calls. Floored at 250 by draft-stream " + "itself. Default 300 for draft transport (DMs) and 1000 for " + "message transport (groups/forums). Override per-agent if a " + "particular agent needs snappier or quieter streaming."),
|
|
11840
11840
|
clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message \u2014 Reading X, Searching the web for Y, \u2026) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) \u2014 no post-then-delete. Per-agent " + "override; cascades defaults \u2192 profile \u2192 agent (per-key)."),
|
|
11841
|
-
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md
|
|
11841
|
+
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md) is re-injected on every turn via " + "the UserPromptSubmit hook instead of baked into --append-system-prompt " + "at session start. Lets workspace edits propagate without a restart. " + "Costs ~5-10% per-turn latency/spend since the stable prefix is no " + "longer prompt-cached."),
|
|
11842
|
+
inject_on_change: exports_external.boolean().optional().describe("Context-efficiency gate for per-turn hook injection (default true). " + "When true (the default), the turn-pacing directive and dynamic " + "workspace content are only re-emitted when their content changes or " + "the session_id changes \u2014 suppressing redundant injection that " + "otherwise triples compaction frequency. Set to false to revert to " + "the legacy always-emit behaviour (every turn injects the full " + "content regardless of whether it changed)."),
|
|
11842
11843
|
orphan_promotion_ms: exports_external.number().int().nonnegative().optional().describe("How long (ms) a parent turn waits for a sub-agent JSONL watcher " + "to deliver sub_agent_started before the heartbeat promotes the spawn " + "to a synthesised 'running' row. Default 5000. Set to 0 to disable " + "orphan promotion entirely."),
|
|
11843
11844
|
cold_sub_agent_threshold_ms: exports_external.number().int().nonnegative().optional().describe("JSONL-cold threshold (ms). When a running sub-agent emits no events " + "for this long, the heartbeat synthesises a turn_end for it so the " + "deferred-completion path can proceed. Default 30000. Set to 0 to " + "disable the synthetic close."),
|
|
11844
11845
|
deferred_completion_timeout_ms: exports_external.number().int().nonnegative().optional().describe("Force-close timeout (ms) for deferred sub-agent completion. After " + "the parent turn_end arrives while sub-agents are still running, the " + "card is force-closed after this many ms even if sub-agents never " + "finish. Watcher-disconnect safety net. Default 180000 (3 min)."),
|