@geravant/sinain 1.13.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.
Files changed (78) hide show
  1. package/.env.example +33 -27
  2. package/cli.js +30 -14
  3. package/config-shared.js +173 -30
  4. package/launcher.js +38 -21
  5. package/onboard.js +36 -20
  6. package/package.json +4 -1
  7. package/sinain-agent/run.sh +600 -127
  8. package/sinain-core/src/agents-loader.ts +254 -0
  9. package/sinain-core/src/buffers/feed-buffer.ts +6 -4
  10. package/sinain-core/src/config.ts +77 -15
  11. package/sinain-core/src/escalation/escalator.ts +178 -18
  12. package/sinain-core/src/index.ts +218 -31
  13. package/sinain-core/src/learning/local-curation.ts +81 -27
  14. package/sinain-core/src/overlay/commands.ts +25 -0
  15. package/sinain-core/src/overlay/ws-handler.ts +3 -0
  16. package/sinain-core/src/server.ts +101 -10
  17. package/sinain-core/src/types.ts +29 -3
  18. package/sinain-memory/graph_query.py +12 -3
  19. package/sinain-memory/knowledge_integrator.py +194 -10
  20. package/sinain-memory/__pycache__/common.cpython-312.pyc +0 -0
  21. package/sinain-memory/__pycache__/embed_client.cpython-312.pyc +0 -0
  22. package/sinain-memory/__pycache__/graph_query.cpython-312.pyc +0 -0
  23. package/sinain-memory/__pycache__/knowledge_integrator.cpython-312.pyc +0 -0
  24. package/sinain-memory/__pycache__/session_distiller.cpython-312.pyc +0 -0
  25. package/sinain-memory/__pycache__/triplestore.cpython-312.pyc +0 -0
  26. package/sinain-memory/eval/__init__.py +0 -0
  27. package/sinain-memory/eval/__pycache__/__init__.cpython-312.pyc +0 -0
  28. package/sinain-memory/eval/assertions.py +0 -267
  29. package/sinain-memory/eval/benchmarks/__init__.py +0 -0
  30. package/sinain-memory/eval/benchmarks/__pycache__/__init__.cpython-312.pyc +0 -0
  31. package/sinain-memory/eval/benchmarks/__pycache__/base_adapter.cpython-312.pyc +0 -0
  32. package/sinain-memory/eval/benchmarks/__pycache__/config.cpython-312.pyc +0 -0
  33. package/sinain-memory/eval/benchmarks/__pycache__/evaluate.cpython-312.pyc +0 -0
  34. package/sinain-memory/eval/benchmarks/__pycache__/ingest.cpython-312.pyc +0 -0
  35. package/sinain-memory/eval/benchmarks/__pycache__/longmemeval_adapter.cpython-312.pyc +0 -0
  36. package/sinain-memory/eval/benchmarks/__pycache__/meeting_adapter.cpython-312.pyc +0 -0
  37. package/sinain-memory/eval/benchmarks/__pycache__/meeting_runner.cpython-312.pyc +0 -0
  38. package/sinain-memory/eval/benchmarks/__pycache__/query.cpython-312.pyc +0 -0
  39. package/sinain-memory/eval/benchmarks/__pycache__/report.cpython-312.pyc +0 -0
  40. package/sinain-memory/eval/benchmarks/__pycache__/runner.cpython-312.pyc +0 -0
  41. package/sinain-memory/eval/benchmarks/base_adapter.py +0 -43
  42. package/sinain-memory/eval/benchmarks/config.py +0 -23
  43. package/sinain-memory/eval/benchmarks/evaluate.py +0 -146
  44. package/sinain-memory/eval/benchmarks/ingest.py +0 -152
  45. package/sinain-memory/eval/benchmarks/judges/__init__.py +0 -0
  46. package/sinain-memory/eval/benchmarks/judges/__pycache__/__init__.cpython-312.pyc +0 -0
  47. package/sinain-memory/eval/benchmarks/judges/__pycache__/qa_judge.cpython-312.pyc +0 -0
  48. package/sinain-memory/eval/benchmarks/judges/qa_judge.py +0 -81
  49. package/sinain-memory/eval/benchmarks/longmemeval_adapter.py +0 -177
  50. package/sinain-memory/eval/benchmarks/meeting_adapter.py +0 -81
  51. package/sinain-memory/eval/benchmarks/meeting_runner.py +0 -230
  52. package/sinain-memory/eval/benchmarks/query.py +0 -193
  53. package/sinain-memory/eval/benchmarks/report.py +0 -87
  54. package/sinain-memory/eval/benchmarks/run_meeting_bench.sh +0 -318
  55. package/sinain-memory/eval/benchmarks/runner.py +0 -283
  56. package/sinain-memory/eval/judges/__init__.py +0 -0
  57. package/sinain-memory/eval/judges/base_judge.py +0 -61
  58. package/sinain-memory/eval/judges/curation_judge.py +0 -46
  59. package/sinain-memory/eval/judges/insight_judge.py +0 -48
  60. package/sinain-memory/eval/judges/mining_judge.py +0 -42
  61. package/sinain-memory/eval/judges/signal_judge.py +0 -45
  62. package/sinain-memory/eval/retrieval_benchmark.jsonl +0 -12
  63. package/sinain-memory/eval/retrieval_evaluator.py +0 -186
  64. package/sinain-memory/eval/schemas.py +0 -247
  65. package/sinain-memory/tests/__init__.py +0 -0
  66. package/sinain-memory/tests/conftest.py +0 -189
  67. package/sinain-memory/tests/test_curator_helpers.py +0 -94
  68. package/sinain-memory/tests/test_embedder.py +0 -210
  69. package/sinain-memory/tests/test_extract_json.py +0 -124
  70. package/sinain-memory/tests/test_feedback_computation.py +0 -121
  71. package/sinain-memory/tests/test_miner_helpers.py +0 -71
  72. package/sinain-memory/tests/test_module_management.py +0 -458
  73. package/sinain-memory/tests/test_parsers.py +0 -96
  74. package/sinain-memory/tests/test_tick_evaluator.py +0 -430
  75. package/sinain-memory/tests/test_triple_extractor.py +0 -255
  76. package/sinain-memory/tests/test_triple_ingest.py +0 -191
  77. package/sinain-memory/tests/test_triple_migrate.py +0 -138
  78. package/sinain-memory/tests/test_triplestore.py +0 -248
@@ -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
58
- claude|codex|goose) return 0 ;;
195
+ local check="${1:-$AGENT}"
196
+ local type
197
+ type=$(prof_get_or "$check" type "$check")
198
+ case "$type" in
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)
71
- local turns="${2:-$AGENT_MAX_TURNS}"
72
- if [ -n "${SINAIN_SPAWN:-}" ]; then
73
- # Spawn: PreToolUse hook routes permission prompts to overlay HUD
74
- claude \
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
- claude --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 ---
@@ -227,6 +446,38 @@ print(' sinain extension added to ' + config_path)
227
446
  fi
228
447
  fi
229
448
 
449
+ # Ollama warmup — pin the backing model so each agent invocation hits hot weights.
450
+ # openclaude + Ollama via the OpenAI-compat endpoint does NOT forward keep_alive,
451
+ # so we ping Ollama's native /api/generate once with keep_alive=-1 (persistent).
452
+ # Applies to any agent pointed at an Ollama-compatible endpoint via OPENAI_BASE_URL.
453
+ OLLAMA_WARMUP="${OLLAMA_WARMUP:-true}"
454
+ if [ "$OLLAMA_WARMUP" = "true" ] && [ -n "${OPENAI_BASE_URL:-}" ]; then
455
+ if [[ "$OPENAI_BASE_URL" == *"11434"* ]] || [[ "$OPENAI_BASE_URL" == *"ollama"* ]]; then
456
+ # Derive Ollama host by stripping /v1 suffix from OPENAI_BASE_URL
457
+ OLLAMA_HOST="${OLLAMA_HOST:-${OPENAI_BASE_URL%/v1*}}"
458
+ OLLAMA_MODEL="${OLLAMA_MODEL:-${OPENAI_MODEL:-}}"
459
+ OLLAMA_KEEP_ALIVE="${OLLAMA_KEEP_ALIVE:--1}" # -1 = persistent, or "24h", "30m", etc.
460
+ if [ -n "$OLLAMA_MODEL" ]; then
461
+ echo "Warming Ollama model $OLLAMA_MODEL at $OLLAMA_HOST (keep_alive=$OLLAMA_KEEP_ALIVE)..."
462
+ # Ollama accepts keep_alive as int (-1 = persistent) or duration string ("24h", "30m").
463
+ if [[ "$OLLAMA_KEEP_ALIVE" =~ ^-?[0-9]+$ ]]; then
464
+ WARMUP_PAYLOAD="{\"model\":\"$OLLAMA_MODEL\",\"prompt\":\"\",\"keep_alive\":$OLLAMA_KEEP_ALIVE,\"stream\":false}"
465
+ else
466
+ WARMUP_PAYLOAD="{\"model\":\"$OLLAMA_MODEL\",\"prompt\":\"\",\"keep_alive\":\"$OLLAMA_KEEP_ALIVE\",\"stream\":false}"
467
+ fi
468
+ if curl -sf -m 60 -X POST "$OLLAMA_HOST/api/generate" \
469
+ -H 'Content-Type: application/json' \
470
+ -d "$WARMUP_PAYLOAD" >/dev/null 2>&1; then
471
+ echo " ✓ Model pinned in memory"
472
+ else
473
+ echo " ⚠ Warmup failed — first request will cold-start the model"
474
+ fi
475
+ else
476
+ echo " ⚠ OLLAMA_WARMUP=true but OPENAI_MODEL not set — skipping warmup"
477
+ fi
478
+ fi
479
+ fi
480
+
230
481
  # Agent mode label
231
482
  if agent_has_mcp; then
232
483
  AGENT_MODE="MCP"
@@ -234,21 +485,219 @@ else
234
485
  AGENT_MODE="pipe"
235
486
  fi
236
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
+
237
685
  echo "sinain bare agent started"
238
686
  echo " Agent: $AGENT ($AGENT_MODE)"
239
687
  echo " Core: $CORE_URL"
240
688
  echo " Allowed: ${ALLOWED_TOOLS:-<none>}"
241
689
  echo " Poll: every ${POLL_INTERVAL}s"
242
- echo " Heartbeat: every ${HEARTBEAT_INTERVAL}s"
243
690
  echo " Press Ctrl+C to stop"
244
691
  echo ""
245
692
 
246
- LAST_HEARTBEAT=$(date +%s)
247
693
  ESCALATION_COUNT=0
248
694
 
249
695
  cleanup() {
250
696
  echo ""
251
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
252
701
  exit 0
253
702
  }
254
703
  trap cleanup INT TERM
@@ -261,20 +710,13 @@ Call sinain_get_escalation to see the full context, then call sinain_respond wit
261
710
 
262
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.'
263
712
 
264
- HEARTBEAT_PROMPT='You are the sinain HUD agent. Run the heartbeat cycle:
265
- 1. Call sinain_heartbeat_tick with a brief session summary (runs signal analysis, session distillation, knowledge integration, insight synthesis)
266
- 2. If the result contains a suggestion or insight, post it to HUD via sinain_post_feed
267
- 3. Call sinain_get_knowledge to review the merged knowledge document (draws from both local and workspace databases)
268
- 4. Optionally call sinain_knowledge_query with relevant entities to check long-term knowledge state
269
- 5. Call sinain_get_feedback to review recent escalation scores
270
-
271
- 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).'
272
-
273
713
  # --- Main loop ---
274
714
 
275
715
  while true; do
276
716
  # Poll for pending escalation
277
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"
278
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)
279
721
  if [ -n "$ESC_PAUSED" ]; then
280
722
  sleep 10 # Slow polling when paused
@@ -282,6 +724,21 @@ while true; do
282
724
  fi
283
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)
284
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
+
285
742
  if [ -n "$ESC_ID" ]; then
286
743
  ESC_MSG=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['escalation']['message'])" 2>/dev/null)
287
744
  ESC_SCORE=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['escalation'].get('score','?'))" 2>/dev/null)
@@ -289,28 +746,52 @@ while true; do
289
746
 
290
747
  echo "[$(date +%H:%M:%S)] Escalation $ESC_ID (score=$ESC_SCORE, coding=$ESC_CODING)"
291
748
 
292
- if agent_has_mcp; then
293
- # 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"
294
754
  PROMPT=$(printf "$ESC_PROMPT_TEMPLATE" "$ESC_ID")
295
- 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
296
757
  else
297
758
  # Pipe path: bash handles HTTP, agent just generates text
298
- RESPONSE=$(invoke_pipe "$ESC_MSG" || true)
759
+ RESPONSE=$(invoke_pipe "$ESC_AGENT" "$ESC_MSG" || true)
299
760
  if [ -n "$RESPONSE" ]; then
300
761
  post_response "$ESC_ID" "$RESPONSE"
301
762
  else
302
- echo "[$(date +%H:%M:%S)] WARNING: $AGENT returned empty response"
763
+ echo "[$(date +%H:%M:%S)] WARNING: $ESC_AGENT returned empty response"
303
764
  fi
304
765
  fi
305
766
 
306
767
  ESCALATION_COUNT=$((ESCALATION_COUNT + 1))
307
- 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
308
782
  echo ""
309
783
  fi
310
784
 
311
- # Poll for pending spawn task (queued via HUD Shift+Enter or POST /spawn)
312
- SPAWN=$(curl -sf "$CORE_URL/spawn/pending" 2>/dev/null || echo '{"ok":false}')
313
- 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
314
795
 
315
796
  if [ -n "$SPAWN_ID" ]; then
316
797
  SPAWN_TASK=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['task']['task'])" 2>/dev/null)
@@ -318,7 +799,7 @@ while true; do
318
799
 
319
800
  echo "[$(date +%H:%M:%S)] Spawn task $SPAWN_ID ($SPAWN_LABEL)"
320
801
 
321
- if agent_has_mcp; then
802
+ if agent_has_mcp "$SPAWN_AGENT"; then
322
803
  # MCP path: agent runs task with sinain tools available
323
804
  # Pre-fetch knowledge context so the spawn doesn't waste turns calling tools
324
805
  SPAWN_KNOWLEDGE=$(curl -sf "$CORE_URL/knowledge" 2>/dev/null | python3 -c "
@@ -335,11 +816,11 @@ $SPAWN_KNOWLEDGE
335
816
  }
336
817
  Complete this task thoroughly. You also have sinain_get_knowledge and sinain_knowledge_query tools available for additional context. Summarize your findings concisely."
337
818
  export SINAIN_SPAWN=1 SINAIN_SPAWN_TASK_ID="$SPAWN_ID"
338
- 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")
339
820
  unset SINAIN_SPAWN SINAIN_SPAWN_TASK_ID
340
821
  else
341
822
  # Pipe path: agent gets task text directly
342
- 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")
343
824
  fi
344
825
 
345
826
  # Post result back
@@ -347,37 +828,29 @@ Complete this task thoroughly. You also have sinain_get_knowledge and sinain_kno
347
828
  curl -sf -X POST "$CORE_URL/spawn/respond" \
348
829
  -H 'Content-Type: application/json' \
349
830
  -d "{\"id\":\"$SPAWN_ID\",\"result\":$(echo "$SPAWN_RESULT" | json_encode)}" >/dev/null 2>&1 || true
350
- echo "[$(date +%H:%M:%S)] Spawn $SPAWN_ID completed: ${SPAWN_RESULT:0:120}..."
351
- fi
352
- echo ""
353
- fi
354
-
355
- # Heartbeat check
356
- NOW=$(date +%s)
357
- ELAPSED=$((NOW - LAST_HEARTBEAT))
358
- if [ "$ELAPSED" -ge "$HEARTBEAT_INTERVAL" ]; then
359
- echo "[$(date +%H:%M:%S)] Running heartbeat tick..."
360
-
361
- if agent_has_mcp; then
362
- # MCP path: agent runs heartbeat tools
363
- invoke_agent "$HEARTBEAT_PROMPT" || true
364
- else
365
- # Pipe path: run curation scripts directly
366
- SCRIPTS_DIR="$WORKSPACE/sinain-memory"
367
- MEMORY_DIR="$WORKSPACE/memory"
368
- if [ -d "$SCRIPTS_DIR" ]; then
369
- python3 "$SCRIPTS_DIR/signal_analyzer.py" --memory-dir "$MEMORY_DIR" 2>/dev/null || true
370
- python3 "$SCRIPTS_DIR/playbook_curator.py" --memory-dir "$MEMORY_DIR" 2>/dev/null || true
371
- 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
372
843
  else
373
- 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}..."
374
845
  fi
375
846
  fi
376
-
377
- LAST_HEARTBEAT=$NOW
378
- echo "[$(date +%H:%M:%S)] Heartbeat complete"
379
847
  echo ""
380
848
  fi
381
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
+
382
855
  sleep "$POLL_INTERVAL"
383
856
  done