@geravant/sinain 1.14.0 → 1.15.0
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/.env.example +33 -29
- package/cli.js +30 -14
- package/config-shared.js +172 -30
- package/launcher.js +38 -21
- package/onboard.js +36 -20
- package/package.json +1 -1
- package/sinain-agent/run.sh +567 -126
- package/sinain-core/src/agents-loader.ts +254 -0
- package/sinain-core/src/config.ts +77 -15
- package/sinain-core/src/escalation/escalator.ts +178 -18
- package/sinain-core/src/index.ts +168 -12
- package/sinain-core/src/learning/local-curation.ts +81 -27
- package/sinain-core/src/overlay/commands.ts +25 -0
- package/sinain-core/src/overlay/ws-handler.ts +3 -0
- package/sinain-core/src/server.ts +101 -10
- package/sinain-core/src/types.ts +29 -3
package/sinain-agent/run.sh
CHANGED
|
@@ -26,101 +26,309 @@ fi
|
|
|
26
26
|
|
|
27
27
|
MCP_CONFIG="${MCP_CONFIG:-$SCRIPT_DIR/mcp-config.json}"
|
|
28
28
|
CORE_URL="${SINAIN_CORE_URL:-http://localhost:9500}"
|
|
29
|
-
POLL_INTERVAL="${SINAIN_POLL_INTERVAL:-2}"
|
|
30
|
-
HEARTBEAT_INTERVAL="${SINAIN_HEARTBEAT_INTERVAL:-900}" # 15 minutes
|
|
31
|
-
AGENT="${SINAIN_AGENT:-claude}"
|
|
32
29
|
WORKSPACE="${SINAIN_WORKSPACE:-$HOME/.openclaw/workspace}"
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
|
|
31
|
+
# --- agents.json early bootstrap + top-level helper ---
|
|
32
|
+
# We need top-level fields (default agent, turns, allowed tools, poll
|
|
33
|
+
# interval) BEFORE the per-profile loader runs further down. So compute
|
|
34
|
+
# the file path here and define a helper to read individual fields.
|
|
35
|
+
#
|
|
36
|
+
# Path priority (highest first):
|
|
37
|
+
# 1. $AGENTS_CONFIG_PATH (explicit env override)
|
|
38
|
+
# 2. ~/.sinain/agents.json (wizard write target — works on npm installs
|
|
39
|
+
# where the package dir is read-only)
|
|
40
|
+
# 3. $SCRIPT_DIR/agents.json (legacy/dev-repo location)
|
|
41
|
+
# agents.example.json (committed template) is the bootstrap source if
|
|
42
|
+
# none of the above exists yet.
|
|
43
|
+
AGENTS_EXAMPLE="$SCRIPT_DIR/agents.example.json"
|
|
44
|
+
USER_AGENTS_FILE="$HOME/.sinain/agents.json"
|
|
45
|
+
if [ -n "${AGENTS_CONFIG_PATH:-}" ]; then
|
|
46
|
+
AGENTS_FILE="$AGENTS_CONFIG_PATH"
|
|
47
|
+
elif [ -f "$USER_AGENTS_FILE" ]; then
|
|
48
|
+
AGENTS_FILE="$USER_AGENTS_FILE"
|
|
49
|
+
else
|
|
50
|
+
AGENTS_FILE="$SCRIPT_DIR/agents.json"
|
|
51
|
+
fi
|
|
52
|
+
# First-run bootstrap: prefer dev location ($SCRIPT_DIR/agents.json) when
|
|
53
|
+
# the package dir is writable; otherwise seed user home (~/.sinain).
|
|
54
|
+
if [ ! -f "$AGENTS_FILE" ] && [ -f "$AGENTS_EXAMPLE" ]; then
|
|
55
|
+
if [ -w "$SCRIPT_DIR" ] && [ "$AGENTS_FILE" = "$SCRIPT_DIR/agents.json" ]; then
|
|
56
|
+
echo " Creating $AGENTS_FILE from agents.example.json (first-run bootstrap)"
|
|
57
|
+
cp "$AGENTS_EXAMPLE" "$AGENTS_FILE"
|
|
58
|
+
else
|
|
59
|
+
mkdir -p "$HOME/.sinain"
|
|
60
|
+
AGENTS_FILE="$USER_AGENTS_FILE"
|
|
61
|
+
echo " Creating $AGENTS_FILE from agents.example.json (first-run bootstrap)"
|
|
62
|
+
cp "$AGENTS_EXAMPLE" "$AGENTS_FILE"
|
|
63
|
+
fi
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# agents_get <key> [default]
|
|
67
|
+
# Reads a top-level scalar field from agents.json. Supports cross-field
|
|
68
|
+
# substitution: a value like "${allowedTools} Bash(git:*)" resolves
|
|
69
|
+
# `${allowedTools}` against the same JSON before being returned. Env-var
|
|
70
|
+
# expansion happens via apply_profile_env later (or by shell when the
|
|
71
|
+
# value is exported), so we stop at one substitution pass here.
|
|
72
|
+
agents_get() {
|
|
73
|
+
local key="$1" def="${2:-}"
|
|
74
|
+
local val
|
|
75
|
+
if [ ! -f "$AGENTS_FILE" ]; then
|
|
76
|
+
printf '%s' "$def"
|
|
77
|
+
return 0
|
|
78
|
+
fi
|
|
79
|
+
val=$(python3 - "$AGENTS_FILE" "$key" <<'PY' 2>/dev/null
|
|
80
|
+
import json, sys, re
|
|
81
|
+
path, key = sys.argv[1], sys.argv[2]
|
|
82
|
+
try:
|
|
83
|
+
with open(path) as f:
|
|
84
|
+
data = json.load(f)
|
|
85
|
+
except Exception:
|
|
86
|
+
sys.exit(0)
|
|
87
|
+
val = data.get(key)
|
|
88
|
+
if val is None:
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
if isinstance(val, str):
|
|
91
|
+
def repl(m):
|
|
92
|
+
other = data.get(m.group(1))
|
|
93
|
+
return other if isinstance(other, str) else m.group(0)
|
|
94
|
+
val = re.sub(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}', repl, val)
|
|
95
|
+
print(val)
|
|
96
|
+
PY
|
|
97
|
+
)
|
|
98
|
+
if [ -z "$val" ]; then
|
|
99
|
+
printf '%s' "$def"
|
|
100
|
+
else
|
|
101
|
+
printf '%s' "$val"
|
|
102
|
+
fi
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Top-level config: agents.json wins, env vars are honored as a fallback
|
|
106
|
+
# (during the migration window — once .env is fully cleaned up, the
|
|
107
|
+
# env reads become dead code).
|
|
108
|
+
POLL_INTERVAL="${SINAIN_POLL_INTERVAL:-$(agents_get pollIntervalSec 2)}"
|
|
109
|
+
AGENT="${SINAIN_AGENT:-$(agents_get default claude)}"
|
|
110
|
+
AGENT_MAX_TURNS="${SINAIN_AGENT_MAX_TURNS:-$(agents_get agentMaxTurns 5)}"
|
|
111
|
+
SPAWN_MAX_TURNS="${SINAIN_SPAWN_MAX_TURNS:-$(agents_get spawnMaxTurns 25)}"
|
|
35
112
|
|
|
36
113
|
# Build allowed tools list for Claude's --allowedTools flag.
|
|
37
|
-
# SINAIN_ALLOWED_TOOLS
|
|
114
|
+
# Priority: SINAIN_ALLOWED_TOOLS env > agents.json `allowedTools` >
|
|
115
|
+
# auto-derive from MCP config > "mcp__sinain" hardcoded default.
|
|
38
116
|
if [ -n "${SINAIN_ALLOWED_TOOLS:-}" ]; then
|
|
39
117
|
ALLOWED_TOOLS="$SINAIN_ALLOWED_TOOLS"
|
|
40
|
-
|
|
41
|
-
ALLOWED_TOOLS=$(
|
|
118
|
+
else
|
|
119
|
+
ALLOWED_TOOLS=$(agents_get allowedTools "")
|
|
120
|
+
if [ -z "$ALLOWED_TOOLS" ] && [ -f "$MCP_CONFIG" ]; then
|
|
121
|
+
ALLOWED_TOOLS=$(python3 -c "
|
|
42
122
|
import json
|
|
43
123
|
with open('$MCP_CONFIG') as f:
|
|
44
124
|
cfg = json.load(f)
|
|
45
125
|
print(' '.join('mcp__' + s for s in cfg.get('mcpServers', {})))
|
|
46
126
|
" 2>/dev/null || echo "mcp__sinain")
|
|
47
|
-
|
|
48
|
-
ALLOWED_TOOLS="mcp__sinain"
|
|
127
|
+
fi
|
|
128
|
+
[ -z "$ALLOWED_TOOLS" ] && ALLOWED_TOOLS="mcp__sinain"
|
|
49
129
|
fi
|
|
50
130
|
|
|
131
|
+
# Per-lane allowed tools — agents.json wins, env vars override per-host.
|
|
132
|
+
# Note: agents.json's `escAllowedTools` already substitutes ${allowedTools}
|
|
133
|
+
# during agents_get, so the value is fully composed by the time we read it.
|
|
134
|
+
SINAIN_ESC_ALLOWED_TOOLS="${SINAIN_ESC_ALLOWED_TOOLS:-$(agents_get escAllowedTools "")}"
|
|
135
|
+
SINAIN_SPAWN_ALLOWED_TOOLS="${SINAIN_SPAWN_ALLOWED_TOOLS:-$(agents_get spawnAllowedTools "")}"
|
|
136
|
+
export SINAIN_ESC_ALLOWED_TOOLS SINAIN_SPAWN_ALLOWED_TOOLS
|
|
137
|
+
|
|
51
138
|
# --- Agent profiles ---
|
|
52
139
|
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
140
|
+
# --- Agent profile registry (loaded from agents.json at startup) ---
|
|
141
|
+
# Each profile maps a roster name → {bin, type, settings, model, env}.
|
|
142
|
+
# The roster (AVAILABLE_AGENTS) is profile names whose bin is on PATH;
|
|
143
|
+
# invoke_agent dispatches by profile.type and substitutes profile.bin.
|
|
144
|
+
# This lets users add custom names like "pclaude" pointing at a binary
|
|
145
|
+
# of the same kind as claude but with different env/settings.
|
|
146
|
+
#
|
|
147
|
+
# Implemented with namespaced variables (PROFILE_<UPPER_NAME>_<FIELD>)
|
|
148
|
+
# instead of bash-4 associative arrays so the script runs on macOS's
|
|
149
|
+
# default bash 3.2.
|
|
150
|
+
ALL_PROFILES=()
|
|
151
|
+
JUNIE_HAS_MCP=false # set during startup checks for the junie-typed profile
|
|
152
|
+
|
|
153
|
+
# Internal: build the namespaced variable name for (profile, field).
|
|
154
|
+
# Profile names like "pclaude" → "PCLAUDE"; "openclaude-spawn" → "OPENCLAUDE_SPAWN".
|
|
155
|
+
_prof_var() {
|
|
156
|
+
local name="$1" field="$2"
|
|
157
|
+
local safe_name
|
|
158
|
+
safe_name=$(echo "$name" | tr 'a-z-' 'A-Z_')
|
|
159
|
+
local field_upper
|
|
160
|
+
field_upper=$(echo "$field" | tr 'a-z' 'A-Z')
|
|
161
|
+
echo "PROFILE_${safe_name}_${field_upper}"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
prof_set() {
|
|
165
|
+
local name="$1" field="$2" value="$3"
|
|
166
|
+
local var
|
|
167
|
+
var=$(_prof_var "$name" "$field")
|
|
168
|
+
printf -v "$var" '%s' "$value"
|
|
169
|
+
# Track distinct profile names in ALL_PROFILES (no duplicates).
|
|
170
|
+
local found=false p
|
|
171
|
+
for p in "${ALL_PROFILES[@]:-}"; do
|
|
172
|
+
[ "$p" = "$name" ] && { found=true; break; }
|
|
173
|
+
done
|
|
174
|
+
$found || ALL_PROFILES+=("$name")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
prof_get() {
|
|
178
|
+
local name="$1" field="$2"
|
|
179
|
+
local var
|
|
180
|
+
var=$(_prof_var "$name" "$field")
|
|
181
|
+
echo "${!var:-}"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# Read with a fallback default (last arg).
|
|
185
|
+
prof_get_or() {
|
|
186
|
+
local val
|
|
187
|
+
val=$(prof_get "$1" "$2")
|
|
188
|
+
echo "${val:-$3}"
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Returns 0 if the selected profile's TYPE supports MCP tools natively.
|
|
192
|
+
# Optional arg: profile name. Defaults to $AGENT for back-compat with
|
|
193
|
+
# startup-time usage. Main loop passes the lane-specific choice.
|
|
56
194
|
agent_has_mcp() {
|
|
57
|
-
|
|
195
|
+
local check="${1:-$AGENT}"
|
|
196
|
+
local type
|
|
197
|
+
type=$(prof_get_or "$check" type "$check")
|
|
198
|
+
case "$type" in
|
|
58
199
|
claude|openclaude|codex|goose) return 0 ;;
|
|
59
200
|
junie) $JUNIE_HAS_MCP ;;
|
|
60
201
|
*) return 1 ;;
|
|
61
202
|
esac
|
|
62
203
|
}
|
|
63
204
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
205
|
+
# Apply per-profile env overrides in the current (sub)shell. Values may
|
|
206
|
+
# use ${VAR} indirection (anywhere in the string, e.g. "${HOME}/sub/path"
|
|
207
|
+
# or "${PERSONAL_OPENAI_API_KEY}") to pull from the parent's environment
|
|
208
|
+
# so secrets stay in .env, not in agents.json. Only ${...} braced refs are
|
|
209
|
+
# expanded — bare $VAR and $(cmd) are left literal to avoid surprises and
|
|
210
|
+
# command-injection risk from a typo'd config.
|
|
211
|
+
apply_profile_env() {
|
|
212
|
+
local profile="$1"
|
|
213
|
+
local env_str
|
|
214
|
+
env_str=$(prof_get "$profile" env)
|
|
215
|
+
[ -z "$env_str" ] && return 0
|
|
216
|
+
while IFS= read -r line; do
|
|
217
|
+
[ -z "$line" ] && continue
|
|
218
|
+
local k="${line%%=*}"
|
|
219
|
+
local v="${line#*=}"
|
|
220
|
+
# Inline ${VAR} expansion via python — handles both whole-value and
|
|
221
|
+
# embedded references. python sees env via os.environ, so we don't
|
|
222
|
+
# need to shell-export anything ahead of time.
|
|
223
|
+
if [[ "$v" == *'${'* ]]; then
|
|
224
|
+
v=$(printf '%s' "$v" | python3 -c 'import os,re,sys; sys.stdout.write(re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", lambda m: os.environ.get(m.group(1), ""), sys.stdin.read()))')
|
|
225
|
+
fi
|
|
226
|
+
export "$k=$v"
|
|
227
|
+
done <<< "$env_str"
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Invoke a profile with a prompt. First arg is the profile NAME (looked up
|
|
231
|
+
# in PROFILE_*); second is the prompt; third optional is turns. Profile's
|
|
232
|
+
# bin/type/settings/model/env apply per-invocation. Body runs in a subshell
|
|
233
|
+
# so apply_profile_env's exports don't leak into the parent.
|
|
234
|
+
# Returns text on stdout. Exit code 1 means "type doesn't support MCP —
|
|
235
|
+
# use pipe mode instead".
|
|
67
236
|
invoke_agent() {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
237
|
+
(
|
|
238
|
+
local profile="$1"
|
|
239
|
+
local prompt="$2"
|
|
240
|
+
local turns="${3:-$AGENT_MAX_TURNS}"
|
|
241
|
+
local bin type settings model
|
|
242
|
+
bin=$(prof_get_or "$profile" bin "$profile")
|
|
243
|
+
type=$(prof_get_or "$profile" type "$profile")
|
|
244
|
+
settings=$(prof_get_or "$profile" settings "$SCRIPT_DIR/.claude/settings.json")
|
|
245
|
+
model=$(prof_get "$profile" model)
|
|
246
|
+
apply_profile_env "$profile"
|
|
247
|
+
# If the profile pinned a model, override OPENAI_MODEL for this call only.
|
|
248
|
+
[ -n "$model" ] && export OPENAI_MODEL="$model"
|
|
249
|
+
|
|
250
|
+
case "$type" in
|
|
251
|
+
claude|openclaude)
|
|
252
|
+
# Stderr filter: drops openclaude's repeated "not in context window table"
|
|
253
|
+
# warnings (one per LLM call, ~40/escalation). All other stderr passes through.
|
|
254
|
+
# No-op for claude (it doesn't emit that line). Toggle with QUIET_OPENCLAUDE=false.
|
|
255
|
+
local quiet="${QUIET_OPENCLAUDE:-true}"
|
|
256
|
+
if [ -n "${SINAIN_SPAWN:-}" ]; then
|
|
257
|
+
# Spawn path: user-initiated tasks often need git/edit/write. The
|
|
258
|
+
# --allowedTools whitelist is a pre-invocation gate; PreToolUse hook
|
|
259
|
+
# still routes each call to the overlay for user Allow/Deny. Widen the
|
|
260
|
+
# whitelist so the hook can do its job. Override via SINAIN_SPAWN_ALLOWED_TOOLS.
|
|
261
|
+
local spawn_allowed="${SINAIN_SPAWN_ALLOWED_TOOLS:-${ALLOWED_TOOLS} Bash(git:*) Edit Write Read Glob Grep LS}"
|
|
262
|
+
if [ "$quiet" = "true" ]; then
|
|
263
|
+
"$bin" \
|
|
264
|
+
--mcp-config "$MCP_CONFIG" \
|
|
265
|
+
--settings "$settings" \
|
|
266
|
+
--allowedTools $spawn_allowed \
|
|
267
|
+
--max-turns "$turns" --output-format text \
|
|
268
|
+
-p "$prompt" \
|
|
269
|
+
2> >(grep -v "not in context window table" >&2)
|
|
270
|
+
else
|
|
271
|
+
"$bin" \
|
|
272
|
+
--mcp-config "$MCP_CONFIG" \
|
|
273
|
+
--settings "$settings" \
|
|
274
|
+
--allowedTools $spawn_allowed \
|
|
275
|
+
--max-turns "$turns" --output-format text \
|
|
276
|
+
-p "$prompt"
|
|
277
|
+
fi
|
|
278
|
+
else
|
|
279
|
+
# Escalation path. Override via SINAIN_ESC_ALLOWED_TOOLS.
|
|
280
|
+
local esc_allowed="${SINAIN_ESC_ALLOWED_TOOLS:-${ALLOWED_TOOLS} Bash(git:*) Edit Write Read Glob Grep LS}"
|
|
281
|
+
if [ "$quiet" = "true" ]; then
|
|
282
|
+
"$bin" \
|
|
283
|
+
--mcp-config "$MCP_CONFIG" \
|
|
284
|
+
--settings "$settings" \
|
|
285
|
+
--allowedTools $esc_allowed \
|
|
286
|
+
--max-turns "$turns" --output-format text \
|
|
287
|
+
-p "$prompt" \
|
|
288
|
+
2> >(grep -v "not in context window table" >&2)
|
|
289
|
+
else
|
|
290
|
+
"$bin" \
|
|
291
|
+
--mcp-config "$MCP_CONFIG" \
|
|
292
|
+
--settings "$settings" \
|
|
293
|
+
--allowedTools $esc_allowed \
|
|
294
|
+
--max-turns "$turns" --output-format text \
|
|
295
|
+
-p "$prompt"
|
|
296
|
+
fi
|
|
99
297
|
fi
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
298
|
+
;;
|
|
299
|
+
codex)
|
|
300
|
+
"$bin" exec -s danger-full-access \
|
|
301
|
+
--dangerously-bypass-approvals-and-sandbox \
|
|
302
|
+
--skip-git-repo-check \
|
|
303
|
+
"$prompt"
|
|
304
|
+
;;
|
|
305
|
+
junie)
|
|
306
|
+
if $JUNIE_HAS_MCP; then
|
|
307
|
+
if [ ! -f "$HOME/.junie/allowlist.json" ]; then
|
|
308
|
+
echo " ⚠ Junie: no allowlist.json — MCP tools may prompt. Run junie --brave once to create it." >&2
|
|
309
|
+
fi
|
|
310
|
+
"$bin" --output-format text \
|
|
311
|
+
--mcp-location "$JUNIE_MCP_DIR" \
|
|
312
|
+
--task "$prompt"
|
|
313
|
+
else
|
|
314
|
+
return 1
|
|
315
|
+
fi
|
|
316
|
+
;;
|
|
317
|
+
goose)
|
|
318
|
+
GOOSE_MODE=auto "$bin" run --text "$prompt" \
|
|
319
|
+
--output-format text \
|
|
320
|
+
--quiet \
|
|
321
|
+
--no-session \
|
|
322
|
+
--max-turns "$turns"
|
|
323
|
+
;;
|
|
324
|
+
aider)
|
|
325
|
+
return 1 # No MCP support — caller falls back to invoke_pipe
|
|
326
|
+
;;
|
|
327
|
+
*)
|
|
328
|
+
return 1 # Unknown type — caller falls back to invoke_pipe
|
|
329
|
+
;;
|
|
330
|
+
esac
|
|
331
|
+
)
|
|
124
332
|
}
|
|
125
333
|
|
|
126
334
|
# --- Pipe-mode helpers (for agents without MCP) ---
|
|
@@ -141,19 +349,30 @@ post_response() {
|
|
|
141
349
|
# Invoke a pipe-mode agent with escalation message text.
|
|
142
350
|
# Some agents take the message as an argument, others via stdin.
|
|
143
351
|
invoke_pipe() {
|
|
144
|
-
|
|
352
|
+
(
|
|
353
|
+
local profile="$1"
|
|
354
|
+
local msg="$2"
|
|
355
|
+
local bin type
|
|
356
|
+
bin=$(prof_get_or "$profile" bin "$profile")
|
|
357
|
+
type=$(prof_get_or "$profile" type "$profile")
|
|
358
|
+
apply_profile_env "$profile"
|
|
359
|
+
# Below uses $bin instead of $AGENT and $type for the case-dispatch
|
|
360
|
+
# selector. Variable named AGENT is preserved as alias for type-only
|
|
361
|
+
# lookups to keep the body diff minimal.
|
|
362
|
+
local AGENT="$type"
|
|
145
363
|
case "$AGENT" in
|
|
146
364
|
junie)
|
|
147
|
-
|
|
365
|
+
"$bin" --output-format text --task "$msg"
|
|
148
366
|
;;
|
|
149
367
|
aider)
|
|
150
|
-
|
|
368
|
+
"$bin" --yes -m "$msg"
|
|
151
369
|
;;
|
|
152
370
|
*)
|
|
153
|
-
# Generic: pipe message to stdin
|
|
154
|
-
echo "$msg" | $
|
|
371
|
+
# Generic: pipe message to stdin to whatever binary the profile names
|
|
372
|
+
echo "$msg" | "$bin" 2>/dev/null
|
|
155
373
|
;;
|
|
156
374
|
esac
|
|
375
|
+
)
|
|
157
376
|
}
|
|
158
377
|
|
|
159
378
|
# --- Startup checks ---
|
|
@@ -266,21 +485,219 @@ else
|
|
|
266
485
|
AGENT_MODE="pipe"
|
|
267
486
|
fi
|
|
268
487
|
|
|
488
|
+
# --- Load agent profiles from agents.json ---
|
|
489
|
+
# Built-in defaults are 1:1 (profile name == binary == type). Users can
|
|
490
|
+
# override fields or add custom profiles by editing sinain-agent/agents.json.
|
|
491
|
+
# Profiles whose binaries aren't in PATH are silently skipped.
|
|
492
|
+
for default_name in claude openclaude codex goose junie aider; do
|
|
493
|
+
prof_set "$default_name" bin "$default_name"
|
|
494
|
+
prof_set "$default_name" type "$default_name"
|
|
495
|
+
done
|
|
496
|
+
|
|
497
|
+
# Path + bootstrap for AGENTS_FILE happen earlier in this script (right
|
|
498
|
+
# after CORE_URL is set) so top-level scalars can be read before the
|
|
499
|
+
# default-AGENT block. The full per-profile flatten still happens below.
|
|
500
|
+
if [ -f "$AGENTS_FILE" ]; then
|
|
501
|
+
# Python flattens profiles into "name|field|value" lines; we ingest in
|
|
502
|
+
# the parent shell via process substitution so prof_set writes (which
|
|
503
|
+
# mutate ALL_PROFILES) persist — a piped `while` would run in a
|
|
504
|
+
# subshell and lose them.
|
|
505
|
+
while IFS='|' read -r p_name p_field p_value; do
|
|
506
|
+
[ -z "$p_name" ] && continue
|
|
507
|
+
case "$p_field" in
|
|
508
|
+
bin) prof_set "$p_name" bin "$p_value" ;;
|
|
509
|
+
type) prof_set "$p_name" type "$p_value" ;;
|
|
510
|
+
settings) prof_set "$p_name" settings "${p_value/#\~/$HOME}" ;;
|
|
511
|
+
model) prof_set "$p_name" model "$p_value" ;;
|
|
512
|
+
env)
|
|
513
|
+
# env is multi-line: each "k=v" pair on its own line. Append to
|
|
514
|
+
# any existing env block so multiple env entries accumulate.
|
|
515
|
+
existing_env=$(prof_get "$p_name" env)
|
|
516
|
+
if [ -n "$existing_env" ]; then
|
|
517
|
+
prof_set "$p_name" env "$existing_env"$'\n'"$p_value"
|
|
518
|
+
else
|
|
519
|
+
prof_set "$p_name" env "$p_value"
|
|
520
|
+
fi
|
|
521
|
+
;;
|
|
522
|
+
esac
|
|
523
|
+
done < <(python3 -c '
|
|
524
|
+
import json, sys
|
|
525
|
+
try:
|
|
526
|
+
with open(sys.argv[1]) as f:
|
|
527
|
+
data = json.load(f)
|
|
528
|
+
except Exception as e:
|
|
529
|
+
sys.stderr.write(f"agents.json parse failed: {e}\n")
|
|
530
|
+
sys.exit(0)
|
|
531
|
+
profiles = data.get("profiles", {}) or {}
|
|
532
|
+
for name, prof in profiles.items():
|
|
533
|
+
if not isinstance(prof, dict): continue
|
|
534
|
+
for field in ("bin", "type", "settings", "model"):
|
|
535
|
+
val = prof.get(field)
|
|
536
|
+
if val: print(f"{name}|{field}|{val}")
|
|
537
|
+
env = prof.get("env") or {}
|
|
538
|
+
if isinstance(env, dict):
|
|
539
|
+
for k, v in env.items():
|
|
540
|
+
print(f"{name}|env|{k}={v}")
|
|
541
|
+
' "$AGENTS_FILE")
|
|
542
|
+
fi
|
|
543
|
+
|
|
544
|
+
# Fill in defaults for any profile that didn't specify bin or type.
|
|
545
|
+
# (A profile defined with only env/settings/model still needs bin/type;
|
|
546
|
+
# default both to the profile name for the 1:1 case.)
|
|
547
|
+
for p in "${ALL_PROFILES[@]:-}"; do
|
|
548
|
+
[ -z "$(prof_get "$p" bin)" ] && prof_set "$p" bin "$p"
|
|
549
|
+
[ -z "$(prof_get "$p" type)" ] && prof_set "$p" type "$p"
|
|
550
|
+
done
|
|
551
|
+
|
|
552
|
+
# AVAILABLE_AGENTS = profile names whose configured bin is on PATH.
|
|
553
|
+
# This is what gets POSTed to /bareagent/register and shown in the
|
|
554
|
+
# overlay selector. Lane-specific choices (ESC_AGENT, SPAWN_AGENT)
|
|
555
|
+
# default to $AGENT and are refreshed per-iteration from the config
|
|
556
|
+
# piggyback field on /escalation/pending and /spawn/pending responses.
|
|
557
|
+
AVAILABLE_AGENTS=()
|
|
558
|
+
for p in "${ALL_PROFILES[@]:-}"; do
|
|
559
|
+
# Gateway-style profiles (type=openclaw) have no local binary — they're
|
|
560
|
+
# dispatched by sinain-core via WS RPC. Include them in the roster
|
|
561
|
+
# regardless of PATH so they show up in the overlay's agent selector.
|
|
562
|
+
ptype=$(prof_get_or "$p" type "$p")
|
|
563
|
+
if [ "$ptype" = "openclaw" ]; then
|
|
564
|
+
AVAILABLE_AGENTS+=("$p")
|
|
565
|
+
continue
|
|
566
|
+
fi
|
|
567
|
+
if command -v "$(prof_get_or "$p" bin "$p")" >/dev/null 2>&1; then
|
|
568
|
+
AVAILABLE_AGENTS+=("$p")
|
|
569
|
+
fi
|
|
570
|
+
done
|
|
571
|
+
|
|
572
|
+
# Sanity check the configured default agent
|
|
573
|
+
AGENT_BIN=$(prof_get_or "$AGENT" bin "$AGENT")
|
|
574
|
+
if ! command -v "$AGENT_BIN" >/dev/null 2>&1; then
|
|
575
|
+
echo " ⚠ configured agent '$AGENT' (bin=$AGENT_BIN) not installed — waiting for overlay override"
|
|
576
|
+
fi
|
|
577
|
+
|
|
578
|
+
ESC_AGENT="$AGENT"
|
|
579
|
+
SPAWN_AGENT="$AGENT"
|
|
580
|
+
|
|
581
|
+
# Register roster with sinain-core (fire-and-forget; core may not be ready)
|
|
582
|
+
if [ ${#AVAILABLE_AGENTS[@]} -gt 0 ]; then
|
|
583
|
+
REGISTER_PAYLOAD=$(python3 -c "
|
|
584
|
+
import json, sys
|
|
585
|
+
available = sys.argv[1].split(' ') if sys.argv[1] else []
|
|
586
|
+
print(json.dumps({'available': available, 'current': sys.argv[2]}))
|
|
587
|
+
" "${AVAILABLE_AGENTS[*]}" "$AGENT")
|
|
588
|
+
curl -sf -m 2 -X POST "$CORE_URL/bareagent/register" \
|
|
589
|
+
-H 'Content-Type: application/json' \
|
|
590
|
+
-d "$REGISTER_PAYLOAD" >/dev/null 2>&1 || true
|
|
591
|
+
fi
|
|
592
|
+
echo " Agents available: ${AVAILABLE_AGENTS[*]:-<none>}"
|
|
593
|
+
echo " Lanes: escalation=$ESC_AGENT spawn=$SPAWN_AGENT"
|
|
594
|
+
|
|
595
|
+
# --- Apply config piggybacked on escalation/spawn poll responses ---
|
|
596
|
+
# No separate polling — parses the `config` field from /escalation/pending
|
|
597
|
+
# and /spawn/pending response JSONs. Heals only when core explicitly signals
|
|
598
|
+
# `registered: false` (core forgot our roster, probably restarted) — NOT
|
|
599
|
+
# when the user legitimately picks Off/Off from the selector.
|
|
600
|
+
apply_config_from_response() {
|
|
601
|
+
local json="$1"
|
|
602
|
+
# Short-circuit: only process if the response actually includes a config.
|
|
603
|
+
echo "$json" | grep -q '"config"' || return 0
|
|
604
|
+
local new_esc new_spawn registered
|
|
605
|
+
new_esc=$(echo "$json" | python3 -c "import sys,json; d=json.load(sys.stdin); c=d.get('config') or {}; print(c.get('escalationAgent',''))" 2>/dev/null)
|
|
606
|
+
new_spawn=$(echo "$json" | python3 -c "import sys,json; d=json.load(sys.stdin); c=d.get('config') or {}; print(c.get('spawnAgent',''))" 2>/dev/null)
|
|
607
|
+
registered=$(echo "$json" | python3 -c "import sys,json; d=json.load(sys.stdin); c=d.get('config') or {}; print('true' if c.get('registered') else 'false')" 2>/dev/null)
|
|
608
|
+
|
|
609
|
+
# Healing: core says it doesn't have our roster. Re-register (fire-and-
|
|
610
|
+
# forget). Distinct from "user selected Off/Off" because in that case
|
|
611
|
+
# registered is still true — core knows us, lanes are just blank.
|
|
612
|
+
if [ "$registered" = "false" ] && [ ${#AVAILABLE_AGENTS[@]} -gt 0 ]; then
|
|
613
|
+
echo "[$(date +%H:%M:%S)] core unregistered — re-registering roster"
|
|
614
|
+
local heal_payload
|
|
615
|
+
heal_payload=$(python3 -c "
|
|
616
|
+
import json, sys
|
|
617
|
+
print(json.dumps({'available': sys.argv[1].split(' '), 'current': sys.argv[2]}))
|
|
618
|
+
" "${AVAILABLE_AGENTS[*]}" "${ESC_AGENT:-$AGENT}")
|
|
619
|
+
curl -sf -m 2 -X POST "$CORE_URL/bareagent/register" \
|
|
620
|
+
-H 'Content-Type: application/json' \
|
|
621
|
+
-d "$heal_payload" >/dev/null 2>&1 || true
|
|
622
|
+
return 0
|
|
623
|
+
fi
|
|
624
|
+
|
|
625
|
+
if [ "$new_esc" != "$ESC_AGENT" ]; then
|
|
626
|
+
echo "[$(date +%H:%M:%S)] escalation agent: ${ESC_AGENT:-<off>} → ${new_esc:-<off>}"
|
|
627
|
+
ESC_AGENT="$new_esc"
|
|
628
|
+
fi
|
|
629
|
+
if [ "$new_spawn" != "$SPAWN_AGENT" ]; then
|
|
630
|
+
echo "[$(date +%H:%M:%S)] spawn agent: ${SPAWN_AGENT:-<off>} → ${new_spawn:-<off>}"
|
|
631
|
+
SPAWN_AGENT="$new_spawn"
|
|
632
|
+
fi
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
# --- OpenRouter reasoning-preserving proxy autolaunch ---
|
|
636
|
+
# Starts sinain-agent/openrouter-proxy.mjs when any code path will need it.
|
|
637
|
+
# The proxy preserves reasoning_content across multi-turn MCP flows so
|
|
638
|
+
# DeepSeek V4 Flash (and other thinking models) don't 400 on turn-2.
|
|
639
|
+
#
|
|
640
|
+
# Detection: triggers if (a) the parent shell already has OPENAI_BASE_URL
|
|
641
|
+
# pointed at the proxy port (legacy .env-based config), OR (b) any loaded
|
|
642
|
+
# profile's env block references the proxy port (new agents.json-based
|
|
643
|
+
# config). The second case is what makes "all openclaude routing in
|
|
644
|
+
# agents.json" work without forcing a duplicate OPENAI_BASE_URL in .env.
|
|
645
|
+
PROXY_PID=""
|
|
646
|
+
PROXY_PORT="${OPENROUTER_PROXY_PORT:-11435}"
|
|
647
|
+
PROXY_NEEDED=false
|
|
648
|
+
if [[ "${OPENAI_BASE_URL:-}" == *":${PROXY_PORT}"* ]]; then
|
|
649
|
+
PROXY_NEEDED=true
|
|
650
|
+
else
|
|
651
|
+
for _p in "${ALL_PROFILES[@]:-}"; do
|
|
652
|
+
_env=$(prof_get "$_p" env)
|
|
653
|
+
if [[ "$_env" == *":${PROXY_PORT}"* ]]; then
|
|
654
|
+
PROXY_NEEDED=true; break
|
|
655
|
+
fi
|
|
656
|
+
done
|
|
657
|
+
fi
|
|
658
|
+
if $PROXY_NEEDED; then
|
|
659
|
+
if lsof -iTCP:"$PROXY_PORT" -sTCP:LISTEN >/dev/null 2>&1; then
|
|
660
|
+
echo "OpenRouter proxy already running on :$PROXY_PORT — reusing"
|
|
661
|
+
else
|
|
662
|
+
PROXY_SCRIPT="$SCRIPT_DIR/openrouter-proxy.mjs"
|
|
663
|
+
if [ -f "$PROXY_SCRIPT" ]; then
|
|
664
|
+
PROXY_STDOUT="/tmp/openrouter-proxy.stdout.log"
|
|
665
|
+
echo "Starting OpenRouter proxy (mode=${REASONING_MODE:-preserve})..."
|
|
666
|
+
node "$PROXY_SCRIPT" > "$PROXY_STDOUT" 2>&1 &
|
|
667
|
+
PROXY_PID=$!
|
|
668
|
+
# Wait up to 2s for the proxy to accept connections before proceeding
|
|
669
|
+
for i in 1 2 3 4 5 6 7 8; do
|
|
670
|
+
if lsof -iTCP:"$PROXY_PORT" -sTCP:LISTEN >/dev/null 2>&1; then break; fi
|
|
671
|
+
sleep 0.25
|
|
672
|
+
done
|
|
673
|
+
if kill -0 "$PROXY_PID" 2>/dev/null && lsof -iTCP:"$PROXY_PORT" -sTCP:LISTEN >/dev/null 2>&1; then
|
|
674
|
+
echo " ✓ proxy listening (pid=$PROXY_PID, logs=/tmp/openrouter-proxy.log)"
|
|
675
|
+
else
|
|
676
|
+
echo " ⚠ proxy failed to start — see $PROXY_STDOUT"
|
|
677
|
+
PROXY_PID=""
|
|
678
|
+
fi
|
|
679
|
+
else
|
|
680
|
+
echo " ⚠ OPENAI_BASE_URL points at :$PROXY_PORT but $PROXY_SCRIPT missing"
|
|
681
|
+
fi
|
|
682
|
+
fi
|
|
683
|
+
fi
|
|
684
|
+
|
|
269
685
|
echo "sinain bare agent started"
|
|
270
686
|
echo " Agent: $AGENT ($AGENT_MODE)"
|
|
271
687
|
echo " Core: $CORE_URL"
|
|
272
688
|
echo " Allowed: ${ALLOWED_TOOLS:-<none>}"
|
|
273
689
|
echo " Poll: every ${POLL_INTERVAL}s"
|
|
274
|
-
echo " Heartbeat: every ${HEARTBEAT_INTERVAL}s"
|
|
275
690
|
echo " Press Ctrl+C to stop"
|
|
276
691
|
echo ""
|
|
277
692
|
|
|
278
|
-
LAST_HEARTBEAT=$(date +%s)
|
|
279
693
|
ESCALATION_COUNT=0
|
|
280
694
|
|
|
281
695
|
cleanup() {
|
|
282
696
|
echo ""
|
|
283
697
|
echo "Agent stopped. Escalations handled: $ESCALATION_COUNT"
|
|
698
|
+
if [ -n "${PROXY_PID:-}" ]; then
|
|
699
|
+
kill "$PROXY_PID" 2>/dev/null && echo " stopped OpenRouter proxy (pid=$PROXY_PID)"
|
|
700
|
+
fi
|
|
284
701
|
exit 0
|
|
285
702
|
}
|
|
286
703
|
trap cleanup INT TERM
|
|
@@ -293,20 +710,13 @@ Call sinain_get_escalation to see the full context, then call sinain_respond wit
|
|
|
293
710
|
|
|
294
711
|
Response guidelines: 5-10 sentences, address errors first, reference specific screen/audio context, never NO_REPLY. Max 4000 chars for coding context, 3000 otherwise.'
|
|
295
712
|
|
|
296
|
-
HEARTBEAT_PROMPT='You are the sinain HUD agent. Run the heartbeat cycle:
|
|
297
|
-
1. Call sinain_heartbeat_tick with a brief session summary (runs signal analysis, session distillation, knowledge integration, insight synthesis)
|
|
298
|
-
2. If the result contains a suggestion or insight, post it to HUD via sinain_post_feed
|
|
299
|
-
3. Call sinain_get_knowledge to review the merged knowledge document (draws from both local and workspace databases)
|
|
300
|
-
4. Optionally call sinain_knowledge_query with relevant entities to check long-term knowledge state
|
|
301
|
-
5. Call sinain_get_feedback to review recent escalation scores
|
|
302
|
-
|
|
303
|
-
Knowledge context: sinain-core maintains two knowledge databases — local (session distillation) and workspace (heartbeat curation). The knowledge tools query both via the sinain-core API. Facts have confidence decay (60-day half-life).'
|
|
304
|
-
|
|
305
713
|
# --- Main loop ---
|
|
306
714
|
|
|
307
715
|
while true; do
|
|
308
716
|
# Poll for pending escalation
|
|
309
717
|
ESC=$(curl -sf "$CORE_URL/escalation/pending" 2>/dev/null || echo '{"ok":false}')
|
|
718
|
+
# Pick up per-lane agent choices from the piggybacked config field
|
|
719
|
+
apply_config_from_response "$ESC"
|
|
310
720
|
ESC_PAUSED=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print('true' if d.get('paused') else '')" 2>/dev/null || true)
|
|
311
721
|
if [ -n "$ESC_PAUSED" ]; then
|
|
312
722
|
sleep 10 # Slow polling when paused
|
|
@@ -314,6 +724,21 @@ while true; do
|
|
|
314
724
|
fi
|
|
315
725
|
ESC_ID=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); e=d.get('escalation'); print(e['id'] if e else '')" 2>/dev/null || true)
|
|
316
726
|
|
|
727
|
+
# Escalation lane guard: skip when the lane is Off (empty) OR routed to
|
|
728
|
+
# openclaw (gateway-handled, not local). Posting an "ack-skip" tells
|
|
729
|
+
# sinain-core to drop the pending entry so we don't loop on it forever
|
|
730
|
+
# if the gateway WS is down — the alternative (no response) leaves the
|
|
731
|
+
# escalation in httpPending and we re-poll it every iteration.
|
|
732
|
+
if [ -n "$ESC_ID" ] && [ -z "$ESC_AGENT" ]; then
|
|
733
|
+
echo "[$(date +%H:%M:%S)] Escalation $ESC_ID skipped — lane is Off"
|
|
734
|
+
post_response "$ESC_ID" "[skipped: lane is Off]" 2>/dev/null || true
|
|
735
|
+
ESC_ID=""
|
|
736
|
+
elif [ -n "$ESC_ID" ] && [ "$(prof_get_or "$ESC_AGENT" type "$ESC_AGENT")" = "openclaw" ]; then
|
|
737
|
+
echo "[$(date +%H:%M:%S)] Escalation $ESC_ID skipped — gateway agent '$ESC_AGENT' (type=openclaw) is WS-routed, not a local CLI"
|
|
738
|
+
post_response "$ESC_ID" "[skipped: $ESC_AGENT is gateway-routed; sinain-core should have dispatched via WS]" 2>/dev/null || true
|
|
739
|
+
ESC_ID=""
|
|
740
|
+
fi
|
|
741
|
+
|
|
317
742
|
if [ -n "$ESC_ID" ]; then
|
|
318
743
|
ESC_MSG=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['escalation']['message'])" 2>/dev/null)
|
|
319
744
|
ESC_SCORE=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['escalation'].get('score','?'))" 2>/dev/null)
|
|
@@ -321,28 +746,52 @@ while true; do
|
|
|
321
746
|
|
|
322
747
|
echo "[$(date +%H:%M:%S)] Escalation $ESC_ID (score=$ESC_SCORE, coding=$ESC_CODING)"
|
|
323
748
|
|
|
324
|
-
if agent_has_mcp; then
|
|
325
|
-
# MCP path: agent calls sinain tools directly
|
|
749
|
+
if agent_has_mcp "$ESC_AGENT"; then
|
|
750
|
+
# MCP path: agent calls sinain tools directly. Export a correlation id
|
|
751
|
+
# so the PreToolUse hook can key YOLO on this invocation when the agent
|
|
752
|
+
# runtime doesn't emit session_id in hook input.
|
|
753
|
+
export SINAIN_ESC_TASK_ID="esc-$ESC_ID"
|
|
326
754
|
PROMPT=$(printf "$ESC_PROMPT_TEMPLATE" "$ESC_ID")
|
|
327
|
-
RESPONSE=$(invoke_agent "$PROMPT" || echo "ERROR: $
|
|
755
|
+
RESPONSE=$(invoke_agent "$ESC_AGENT" "$PROMPT" || echo "ERROR: $ESC_AGENT invocation failed")
|
|
756
|
+
unset SINAIN_ESC_TASK_ID
|
|
328
757
|
else
|
|
329
758
|
# Pipe path: bash handles HTTP, agent just generates text
|
|
330
|
-
RESPONSE=$(invoke_pipe "$ESC_MSG" || true)
|
|
759
|
+
RESPONSE=$(invoke_pipe "$ESC_AGENT" "$ESC_MSG" || true)
|
|
331
760
|
if [ -n "$RESPONSE" ]; then
|
|
332
761
|
post_response "$ESC_ID" "$RESPONSE"
|
|
333
762
|
else
|
|
334
|
-
echo "[$(date +%H:%M:%S)] WARNING: $
|
|
763
|
+
echo "[$(date +%H:%M:%S)] WARNING: $ESC_AGENT returned empty response"
|
|
335
764
|
fi
|
|
336
765
|
fi
|
|
337
766
|
|
|
338
767
|
ESCALATION_COUNT=$((ESCALATION_COUNT + 1))
|
|
339
|
-
|
|
768
|
+
# Detect cases where the agent's tool call didn't land on HUD (ID race, max turns, API errors, crashes).
|
|
769
|
+
# On drop: print a short inline summary + append the full response to /tmp/sinain-drops.log for diagnosis.
|
|
770
|
+
if echo "$RESPONSE" | grep -qiE "no pending escalation|id mismatch|Reached max turns|invocation failed|API Error|^Error:"; then
|
|
771
|
+
echo "[$(date +%H:%M:%S)] ⚠ DROP ($ESC_ID) ─────────────────────────────"
|
|
772
|
+
echo "$RESPONSE"
|
|
773
|
+
echo "─────────────────────────────────────────────────────────────"
|
|
774
|
+
{
|
|
775
|
+
echo "===== $(date -u +%Y-%m-%dT%H:%M:%SZ) DROP ($ESC_ID) ====="
|
|
776
|
+
echo "$RESPONSE"
|
|
777
|
+
echo ""
|
|
778
|
+
} >> /tmp/sinain-drops.log
|
|
779
|
+
else
|
|
780
|
+
echo "[$(date +%H:%M:%S)] Responded ($ESCALATION_COUNT total): ${RESPONSE:0:120}..."
|
|
781
|
+
fi
|
|
340
782
|
echo ""
|
|
341
783
|
fi
|
|
342
784
|
|
|
343
|
-
# Poll for pending spawn task (queued via HUD Shift+Enter or POST /spawn)
|
|
344
|
-
|
|
345
|
-
|
|
785
|
+
# Poll for pending spawn task (queued via HUD Shift+Enter or POST /spawn).
|
|
786
|
+
# Skip entirely when the spawn lane is Off — queued tasks will TTL on
|
|
787
|
+
# the server side. This prevents fetching + throwing away task bodies.
|
|
788
|
+
if [ -n "$SPAWN_AGENT" ]; then
|
|
789
|
+
SPAWN=$(curl -sf "$CORE_URL/spawn/pending" 2>/dev/null || echo '{"ok":false}')
|
|
790
|
+
apply_config_from_response "$SPAWN"
|
|
791
|
+
SPAWN_ID=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); t=d.get('task'); print(t['id'] if t else '')" 2>/dev/null || true)
|
|
792
|
+
else
|
|
793
|
+
SPAWN_ID=""
|
|
794
|
+
fi
|
|
346
795
|
|
|
347
796
|
if [ -n "$SPAWN_ID" ]; then
|
|
348
797
|
SPAWN_TASK=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['task']['task'])" 2>/dev/null)
|
|
@@ -350,7 +799,7 @@ while true; do
|
|
|
350
799
|
|
|
351
800
|
echo "[$(date +%H:%M:%S)] Spawn task $SPAWN_ID ($SPAWN_LABEL)"
|
|
352
801
|
|
|
353
|
-
if agent_has_mcp; then
|
|
802
|
+
if agent_has_mcp "$SPAWN_AGENT"; then
|
|
354
803
|
# MCP path: agent runs task with sinain tools available
|
|
355
804
|
# Pre-fetch knowledge context so the spawn doesn't waste turns calling tools
|
|
356
805
|
SPAWN_KNOWLEDGE=$(curl -sf "$CORE_URL/knowledge" 2>/dev/null | python3 -c "
|
|
@@ -367,11 +816,11 @@ $SPAWN_KNOWLEDGE
|
|
|
367
816
|
}
|
|
368
817
|
Complete this task thoroughly. You also have sinain_get_knowledge and sinain_knowledge_query tools available for additional context. Summarize your findings concisely."
|
|
369
818
|
export SINAIN_SPAWN=1 SINAIN_SPAWN_TASK_ID="$SPAWN_ID"
|
|
370
|
-
SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" "$SPAWN_MAX_TURNS" || echo "ERROR:
|
|
819
|
+
SPAWN_RESULT=$(invoke_agent "$SPAWN_AGENT" "$SPAWN_PROMPT" "$SPAWN_MAX_TURNS" || echo "ERROR: $SPAWN_AGENT invocation failed")
|
|
371
820
|
unset SINAIN_SPAWN SINAIN_SPAWN_TASK_ID
|
|
372
821
|
else
|
|
373
822
|
# Pipe path: agent gets task text directly
|
|
374
|
-
SPAWN_RESULT=$(invoke_pipe "Background task: $SPAWN_TASK" || echo "No output")
|
|
823
|
+
SPAWN_RESULT=$(invoke_pipe "$SPAWN_AGENT" "Background task: $SPAWN_TASK" || echo "No output")
|
|
375
824
|
fi
|
|
376
825
|
|
|
377
826
|
# Post result back
|
|
@@ -379,37 +828,29 @@ Complete this task thoroughly. You also have sinain_get_knowledge and sinain_kno
|
|
|
379
828
|
curl -sf -X POST "$CORE_URL/spawn/respond" \
|
|
380
829
|
-H 'Content-Type: application/json' \
|
|
381
830
|
-d "{\"id\":\"$SPAWN_ID\",\"result\":$(echo "$SPAWN_RESULT" | json_encode)}" >/dev/null 2>&1 || true
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
# MCP path: agent runs heartbeat tools
|
|
395
|
-
invoke_agent "$HEARTBEAT_PROMPT" || true
|
|
396
|
-
else
|
|
397
|
-
# Pipe path: run curation scripts directly
|
|
398
|
-
SCRIPTS_DIR="$WORKSPACE/sinain-memory"
|
|
399
|
-
MEMORY_DIR="$WORKSPACE/memory"
|
|
400
|
-
if [ -d "$SCRIPTS_DIR" ]; then
|
|
401
|
-
python3 "$SCRIPTS_DIR/signal_analyzer.py" --memory-dir "$MEMORY_DIR" 2>/dev/null || true
|
|
402
|
-
python3 "$SCRIPTS_DIR/playbook_curator.py" --memory-dir "$MEMORY_DIR" 2>/dev/null || true
|
|
403
|
-
echo "[$(date +%H:%M:%S)] Heartbeat: ran signal_analyzer + playbook_curator"
|
|
831
|
+
# Detect spawn-side errors (401/403/500, auth failures, max-turns,
|
|
832
|
+
# crashes). Print full body inline between dividers + append to
|
|
833
|
+
# /tmp/sinain-drops.log for post-hoc diagnosis, same UX as escalation.
|
|
834
|
+
if echo "$SPAWN_RESULT" | grep -qiE "API Error|unauthorized|401|403|invalid_api_key|Reached max turns|invocation failed|^Error:"; then
|
|
835
|
+
echo "[$(date +%H:%M:%S)] ⚠ SPAWN DROP ($SPAWN_ID) ──────────────────────"
|
|
836
|
+
echo "$SPAWN_RESULT"
|
|
837
|
+
echo "─────────────────────────────────────────────────────────────"
|
|
838
|
+
{
|
|
839
|
+
echo "===== $(date -u +%Y-%m-%dT%H:%M:%SZ) SPAWN DROP ($SPAWN_ID, agent=$SPAWN_AGENT) ====="
|
|
840
|
+
echo "$SPAWN_RESULT"
|
|
841
|
+
echo ""
|
|
842
|
+
} >> /tmp/sinain-drops.log
|
|
404
843
|
else
|
|
405
|
-
echo "[$(date +%H:%M:%S)]
|
|
844
|
+
echo "[$(date +%H:%M:%S)] Spawn $SPAWN_ID completed: ${SPAWN_RESULT:0:120}..."
|
|
406
845
|
fi
|
|
407
846
|
fi
|
|
408
|
-
|
|
409
|
-
LAST_HEARTBEAT=$NOW
|
|
410
|
-
echo "[$(date +%H:%M:%S)] Heartbeat complete"
|
|
411
847
|
echo ""
|
|
412
848
|
fi
|
|
413
849
|
|
|
850
|
+
# Heartbeat moved server-side: sinain-core's LocalCurationService runs the
|
|
851
|
+
# full pipeline (signal_analyzer, insight_synthesizer, feedback_analyzer,
|
|
852
|
+
# memory_miner, playbook_curator) every 30 min natively, and broadcasts
|
|
853
|
+
# insights to the HUD directly. No LLM roundtrip needed here.
|
|
854
|
+
|
|
414
855
|
sleep "$POLL_INTERVAL"
|
|
415
856
|
done
|