@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.
@@ -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
- AGENT_MAX_TURNS="${SINAIN_AGENT_MAX_TURNS:-5}"
34
- SPAWN_MAX_TURNS="${SINAIN_SPAWN_MAX_TURNS:-25}"
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 in .env overrides; otherwise auto-derive from MCP config.
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
- elif [ -f "$MCP_CONFIG" ]; then
41
- ALLOWED_TOOLS=$(python3 -c "
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
- else
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
- # Returns 0 if the selected agent supports MCP tools natively.
54
- # Junie support is detected at startup (JUNIE_HAS_MCP flag).
55
- JUNIE_HAS_MCP=false # set during startup checks
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
- case "$AGENT" in
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
- # Invoke the selected agent with a prompt. MCP-capable agents get the config
65
- # so they can call sinain tools directly. Returns text on stdout.
66
- # Exit code 1 means "agent doesn't support MCP use pipe mode instead".
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
- local prompt="$1"
69
- case "$AGENT" in
70
- claude|openclaude)
71
- local turns="${2:-$AGENT_MAX_TURNS}"
72
- if [ -n "${SINAIN_SPAWN:-}" ]; then
73
- # Spawn: PreToolUse hook routes permission prompts to overlay HUD
74
- "$AGENT" \
75
- --mcp-config "$MCP_CONFIG" \
76
- --settings "$SCRIPT_DIR/.claude/settings.json" \
77
- ${ALLOWED_TOOLS:+--allowedTools $ALLOWED_TOOLS} \
78
- --max-turns "$turns" --output-format text \
79
- -p "$prompt"
80
- else
81
- # Escalation: auto-approve for speed (short-lived, read-heavy)
82
- "$AGENT" --enable-auto-mode \
83
- --mcp-config "$MCP_CONFIG" \
84
- ${ALLOWED_TOOLS:+--allowedTools $ALLOWED_TOOLS} \
85
- --max-turns "$turns" --output-format text \
86
- -p "$prompt"
87
- fi
88
- ;;
89
- codex)
90
- codex exec -s danger-full-access \
91
- --dangerously-bypass-approvals-and-sandbox \
92
- --skip-git-repo-check \
93
- "$prompt"
94
- ;;
95
- junie)
96
- if $JUNIE_HAS_MCP; then
97
- if [ ! -f "$HOME/.junie/allowlist.json" ]; then
98
- echo " Junie: no allowlist.json — MCP tools may prompt. Run junie --brave once to create it." >&2
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
- junie --output-format text \
101
- --mcp-location "$JUNIE_MCP_DIR" \
102
- --task "$prompt"
103
- else
104
- return 1
105
- fi
106
- ;;
107
- goose)
108
- local turns="${2:-$AGENT_MAX_TURNS}"
109
- GOOSE_MODE=auto goose run --text "$prompt" \
110
- --output-format text \
111
- --quiet \
112
- --no-session \
113
- --max-turns "$turns"
114
- ;;
115
- aider)
116
- # No MCP support — signal pipe mode
117
- return 1
118
- ;;
119
- *)
120
- # Generic pipe mode treat AGENT value as a command
121
- return 1
122
- ;;
123
- esac
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
- local msg="$1"
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
- junie --output-format text --task "$msg"
365
+ "$bin" --output-format text --task "$msg"
148
366
  ;;
149
367
  aider)
150
- aider --yes -m "$msg"
368
+ "$bin" --yes -m "$msg"
151
369
  ;;
152
370
  *)
153
- # Generic: pipe message to stdin
154
- echo "$msg" | $AGENT 2>/dev/null
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: $AGENT invocation failed")
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: $AGENT returned empty response"
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
- echo "[$(date +%H:%M:%S)] Responded ($ESCALATION_COUNT total): ${RESPONSE:0:120}..."
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
- SPAWN=$(curl -sf "$CORE_URL/spawn/pending" 2>/dev/null || echo '{"ok":false}')
345
- 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)
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: agent invocation failed")
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
- echo "[$(date +%H:%M:%S)] Spawn $SPAWN_ID completed: ${SPAWN_RESULT:0:120}..."
383
- fi
384
- echo ""
385
- fi
386
-
387
- # Heartbeat check
388
- NOW=$(date +%s)
389
- ELAPSED=$((NOW - LAST_HEARTBEAT))
390
- if [ "$ELAPSED" -ge "$HEARTBEAT_INTERVAL" ]; then
391
- echo "[$(date +%H:%M:%S)] Running heartbeat tick..."
392
-
393
- if agent_has_mcp; then
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)] Heartbeat: skipped (no scripts at $SCRIPTS_DIR)"
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