@geravant/sinain 1.7.1 → 1.8.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 CHANGED
@@ -20,6 +20,11 @@ SINAIN_CORE_URL=http://localhost:9500
20
20
  SINAIN_POLL_INTERVAL=5 # seconds between escalation polls
21
21
  SINAIN_HEARTBEAT_INTERVAL=900 # seconds between heartbeat ticks (15 min)
22
22
  SINAIN_WORKSPACE=~/.openclaw/workspace # knowledge files, curation scripts, playbook
23
+ # SINAIN_ALLOWED_TOOLS=mcp__sinain # MCP tools auto-approved for bare agent
24
+ SINAIN_AGENT_MAX_TURNS=5 # max tool-use turns for escalation responses
25
+ SINAIN_SPAWN_MAX_TURNS=25 # max tool-use turns for spawn tasks (Shift+Enter)
26
+ # auto-derived from mcp-config.json if unset
27
+ # format: mcp__<server> (all) | mcp__<server>__<tool> (specific)
23
28
 
24
29
  # ── Escalation ───────────────────────────────────────────────────────────────
25
30
  ESCALATION_MODE=rich # off | selective | focus | rich
@@ -27,7 +32,7 @@ ESCALATION_MODE=rich # off | selective | focus | rich
27
32
  # selective: score-based (errors, questions trigger it)
28
33
  # focus: always escalate every tick
29
34
  # rich: always escalate with maximum context
30
- ESCALATION_COOLDOWN_MS=30000
35
+ # ESCALATION_COOLDOWN_MS=10000
31
36
  # ESCALATION_TRANSPORT=auto # ws | http | auto
32
37
  # auto = WS when gateway connected, HTTP fallback
33
38
  # http = bare agent only (no gateway)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Ambient intelligence that sees what you see, hears what you hear, and acts on your behalf",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,7 +5,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
5
 
6
6
  # Load .env as fallback — does NOT override vars already in the environment
7
7
  # (e.g. vars set by the launcher from ~/.sinain/.env)
8
- if [ -f "$SCRIPT_DIR/.env" ]; then
8
+ # Load project root .env (single config for all subsystems)
9
+ ENV_FILE="$SCRIPT_DIR/../.env"
10
+ if [ -f "$ENV_FILE" ]; then
9
11
  while IFS='=' read -r key val; do
10
12
  # Skip comments and blank lines
11
13
  [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
@@ -19,15 +21,17 @@ if [ -f "$SCRIPT_DIR/.env" ]; then
19
21
  if [ -z "${!key+x}" ]; then
20
22
  export "$key=$val"
21
23
  fi
22
- done < "$SCRIPT_DIR/.env"
24
+ done < "$ENV_FILE"
23
25
  fi
24
26
 
25
27
  MCP_CONFIG="${MCP_CONFIG:-$SCRIPT_DIR/mcp-config.json}"
26
28
  CORE_URL="${SINAIN_CORE_URL:-http://localhost:9500}"
27
- POLL_INTERVAL="${SINAIN_POLL_INTERVAL:-5}"
29
+ POLL_INTERVAL="${SINAIN_POLL_INTERVAL:-2}"
28
30
  HEARTBEAT_INTERVAL="${SINAIN_HEARTBEAT_INTERVAL:-900}" # 15 minutes
29
31
  AGENT="${SINAIN_AGENT:-claude}"
30
32
  WORKSPACE="${SINAIN_WORKSPACE:-$HOME/.openclaw/workspace}"
33
+ AGENT_MAX_TURNS="${SINAIN_AGENT_MAX_TURNS:-5}"
34
+ SPAWN_MAX_TURNS="${SINAIN_SPAWN_MAX_TURNS:-25}"
31
35
 
32
36
  # Build allowed tools list for Claude's --allowedTools flag.
33
37
  # SINAIN_ALLOWED_TOOLS in .env overrides; otherwise auto-derive from MCP config.
@@ -64,10 +68,11 @@ invoke_agent() {
64
68
  local prompt="$1"
65
69
  case "$AGENT" in
66
70
  claude)
71
+ local turns="${2:-$AGENT_MAX_TURNS}"
67
72
  claude --enable-auto-mode \
68
73
  --mcp-config "$MCP_CONFIG" \
69
74
  ${ALLOWED_TOOLS:+--allowedTools $ALLOWED_TOOLS} \
70
- --max-turns 5 --output-format text \
75
+ --max-turns "$turns" --output-format text \
71
76
  -p "$prompt"
72
77
  ;;
73
78
  codex)
@@ -88,9 +93,10 @@ invoke_agent() {
88
93
  fi
89
94
  ;;
90
95
  goose)
96
+ local turns="${2:-$AGENT_MAX_TURNS}"
91
97
  GOOSE_MODE=auto goose run --text "$prompt" \
92
98
  --output-format text \
93
- --max-turns 10
99
+ --max-turns "$turns"
94
100
  ;;
95
101
  aider)
96
102
  # No MCP support — signal pipe mode
@@ -265,8 +271,8 @@ while true; do
265
271
  # MCP path: agent runs task with sinain tools available
266
272
  SPAWN_PROMPT="You have a background task to complete. Task: $SPAWN_TASK
267
273
 
268
- Complete this task thoroughly. Use sinain_get_knowledge and sinain_knowledge_query if you need context from past sessions. Summarize your findings concisely."
269
- SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" || echo "ERROR: agent invocation failed")
274
+ Complete this task thoroughly. Use sinain_get_knowledge and sinain_knowledge_query if you need context from past sessions. Use web search, file operations, and code execution as needed. Create end-to-end artifacts. Summarize your findings concisely."
275
+ SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" "$SPAWN_MAX_TURNS" || echo "ERROR: agent invocation failed")
270
276
  else
271
277
  # Pipe path: agent gets task text directly
272
278
  SPAWN_RESULT=$(invoke_pipe "Background task: $SPAWN_TASK" || echo "No output")
@@ -56,13 +56,12 @@ You produce outputs as JSON.
56
56
  Respond ONLY with valid JSON. No markdown, no code fences, no explanation.
57
57
  Your entire response must be parseable by JSON.parse().
58
58
 
59
- {"hud":"...","digest":"...","record":{"command":"start"|"stop","label":"..."},"task":"..."}
59
+ {"hud":"...","digest":"...","record":{"command":"start"|"stop","label":"..."}}
60
60
 
61
61
  Output fields:
62
62
  - "hud" (required): max 60 words describing what user is doing NOW
63
63
  - "digest" (required): 5-8 sentences with detailed activity description
64
64
  - "record" (optional): control recording — {"command":"start","label":"Meeting name"} or {"command":"stop"}
65
- - "task" (optional): natural language instruction to spawn a background task
66
65
 
67
66
  When to use "record":
68
67
  - START when user begins a meeting, call, lecture, YouTube video, or important audio content
@@ -70,24 +69,7 @@ When to use "record":
70
69
  - Provide descriptive labels like "Team standup", "Client call", "YouTube: [video title from OCR]"
71
70
  - For YouTube/video content: extract video title from screen OCR for the label
72
71
 
73
- When to use "task":
74
- - User explicitly asks for research, lookup, or action
75
- - Something needs external search or processing that isn't a real-time response
76
- - Example: "Search for React 19 migration guide", "Find docs for this API"
77
-
78
- When to spawn "task" for video content:
79
- - If user watches a YouTube video for 2+ minutes AND no task has been spawned for this video yet, spawn: "Summarize YouTube video: [title or URL from OCR]"
80
- - ONLY spawn ONCE per video - do not repeat spawn for the same video in subsequent ticks
81
- - Extract video title or URL from screen OCR to include in the task
82
-
83
- When to spawn "task" for coding problems:
84
- - If user is actively working on a coding problem/challenge for 1+ minutes:
85
- - Spawn: "Solve coding problem: [problem description/title from OCR]"
86
- - This includes LeetCode, HackerRank, interviews, coding assessments, or any visible coding challenge
87
- - Look for problem signals: "Input:", "Output:", "Example", "Constraints:", problem titles, test cases
88
- - Include as much context as possible from the screen OCR (problem description, examples, constraints)
89
- - ONLY spawn ONCE per distinct problem - do not repeat for the same problem
90
- - The spawned task should provide a complete solution with code and explanation
72
+ Do NOT set a "task" field — background tasks are spawned by user commands only.
91
73
 
92
74
  Audio sources: [\ud83d\udd0a]=system/speaker audio, [\ud83c\udf99]=microphone (user's voice).
93
75
  Treat [\ud83c\udf99] as direct user speech. Treat [\ud83d\udd0a] as external audio.
@@ -75,6 +75,7 @@ export class AgentLoop extends EventEmitter {
75
75
  private running = false;
76
76
  private started = false;
77
77
  private firstTick = true;
78
+ private urgentPending = false;
78
79
 
79
80
  private lastPushedHud = "";
80
81
  private agentNextId = 1;
@@ -137,11 +138,12 @@ export class AgentLoop extends EventEmitter {
137
138
  * Called by sense POST handler and transcription callback.
138
139
  * Triggers debounced analysis.
139
140
  */
140
- onNewContext(): void {
141
+ onNewContext(urgent = false): void {
141
142
  if (!this.started) return;
142
143
 
143
- // Fast first tick: 500ms debounce on startup, normal debounce after
144
- const delay = this.firstTick ? 500 : this.deps.agentConfig.debounceMs;
144
+ // Urgent: user command minimal debounce, bypass cooldown
145
+ const delay = urgent ? 200 : this.firstTick ? 500 : this.deps.agentConfig.debounceMs;
146
+ if (urgent) this.urgentPending = true;
145
147
  if (this.debounceTimer) clearTimeout(this.debounceTimer);
146
148
  this.debounceTimer = setTimeout(() => {
147
149
  this.debounceTimer = null;
@@ -235,8 +237,10 @@ export class AgentLoop extends EventEmitter {
235
237
  if (this.running) return;
236
238
  if (!this.deps.agentConfig.openrouterApiKey) return;
237
239
 
238
- // Cooldown: don't re-analyze within cooldownMs of last run
239
- if (Date.now() - this.lastRunTs < this.deps.agentConfig.cooldownMs) return;
240
+ // Cooldown: don't re-analyze within cooldownMs of last run (unless urgent)
241
+ const isUrgent = this.urgentPending;
242
+ this.urgentPending = false;
243
+ if (!isUrgent && Date.now() - this.lastRunTs < this.deps.agentConfig.cooldownMs) return;
240
244
 
241
245
  // Idle suppression: skip if no new events since last tick
242
246
  const { feedBuffer, senseBuffer } = this.deps;
@@ -11,10 +11,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
11
11
  export let loadedEnvPath: string | undefined;
12
12
 
13
13
  function loadDotEnv(): void {
14
- // Try sinain-core/.env first, then project root .env
14
+ // Try project root .env first, then sinain-core/.env fallback
15
15
  const candidates = [
16
- resolve(__dirname, "..", ".env"),
17
16
  resolve(__dirname, "..", "..", ".env"),
17
+ resolve(__dirname, "..", ".env"),
18
18
  ];
19
19
  for (const envPath of candidates) {
20
20
  if (!existsSync(envPath)) continue;
@@ -13,6 +13,14 @@ import { isCodingContext, buildEscalationMessage, fetchKnowledgeFacts } from "./
13
13
  import { loadPendingTasks, savePendingTasks, type PendingTaskEntry } from "../util/task-store.js";
14
14
  import { log, warn, error } from "../log.js";
15
15
 
16
+ /** Context passed to spawn subagents so they can act on the user's current situation. */
17
+ export interface SpawnContext {
18
+ currentApp?: string;
19
+ digest?: string;
20
+ recentAudio?: string;
21
+ recentScreen?: string;
22
+ }
23
+
16
24
  export interface HttpPendingEscalation {
17
25
  id: string;
18
26
  message: string;
@@ -465,7 +473,7 @@ ${recentLines.join("\n")}`;
465
473
  * Creates a unique child session key and sends the task directly to the gateway
466
474
  * agent RPC — bypassing the main session to avoid dedup/NO_REPLY issues.
467
475
  */
468
- async dispatchSpawnTask(task: string, label?: string): Promise<void> {
476
+ async dispatchSpawnTask(task: string, label?: string, context?: SpawnContext): Promise<void> {
469
477
  // Prevent sibling spawn RPCs from piling up (independent from escalation queue)
470
478
  if (this.spawnInFlight) {
471
479
  log(TAG, `spawn-task skipped — spawn RPC already in-flight`);
@@ -485,9 +493,12 @@ ${recentLines.join("\n")}`;
485
493
  this.lastSpawnFingerprint = fingerprint;
486
494
  this.lastSpawnTs = now;
487
495
 
496
+ // Truncate label to gateway's 64-char limit
497
+ const safeLabel = label?.slice(0, 64);
498
+
488
499
  const taskId = `spawn-${Date.now()}`;
489
500
  const startedAt = Date.now();
490
- const labelStr = label ? ` (label: "${label}")` : "";
501
+ const labelStr = safeLabel ? ` (label: "${safeLabel}")` : "";
491
502
  const idemKey = `spawn-task-${Date.now()}`;
492
503
 
493
504
  // Generate a unique child session key — bypasses the main agent entirely
@@ -498,11 +509,11 @@ ${recentLines.join("\n")}`;
498
509
  log(TAG, `dispatching spawn-task${labelStr} → child=${childSessionKey}: "${task.slice(0, 80)}..."`);
499
510
 
500
511
  // ★ Broadcast "spawned" BEFORE the RPC — TSK tab shows ··· immediately
501
- this.broadcastTaskEvent(taskId, "spawned", label, startedAt);
512
+ this.broadcastTaskEvent(taskId, "spawned", safeLabel, startedAt);
502
513
 
503
514
  if (!this.wsClient.isConnected) {
504
515
  // No OpenClaw gateway — queue for bare agent HTTP polling
505
- this.spawnHttpPending = { id: taskId, task, label: label || "background-task", ts: startedAt };
516
+ this.spawnHttpPending = { id: taskId, task, label: safeLabel || "background-task", ts: startedAt };
506
517
  const preview = task.length > 60 ? task.slice(0, 60) + "…" : task;
507
518
  this.deps.feedBuffer.push(`🔧 Task queued for agent: ${preview}`, "normal", "system", "stream");
508
519
  this.deps.wsHandler.broadcast(`🔧 Task queued for agent: ${preview}`, "normal");
@@ -510,6 +521,10 @@ ${recentLines.join("\n")}`;
510
521
  return;
511
522
  }
512
523
 
524
+ // Dynamic timeout: scale with task length (long transcripts need more time)
525
+ // Base 30s + 1s per 200 chars, min 45s, max 180s
526
+ const timeoutMs = Math.min(180_000, Math.max(45_000, Math.ceil(task.length / 200) * 1000 + 30_000));
527
+
513
528
  // ★ Set spawnInFlight BEFORE first await — cleared in finally regardless of outcome.
514
529
  // Dedicated lane flag: never touches the escalation queue so regular escalations
515
530
  // continue unblocked while this spawn RPC is pending.
@@ -520,11 +535,11 @@ ${recentLines.join("\n")}`;
520
535
  message: task,
521
536
  sessionKey: childSessionKey,
522
537
  lane: "subagent",
523
- extraSystemPrompt: this.buildChildSystemPrompt(task, label),
538
+ extraSystemPrompt: this.buildChildSystemPrompt(context),
524
539
  deliver: false,
525
540
  idempotencyKey: idemKey,
526
- label: label || undefined,
527
- }, 45_000, { expectFinal: true });
541
+ label: safeLabel || undefined,
542
+ }, timeoutMs, { expectFinal: true });
528
543
 
529
544
  log(TAG, `spawn-task RPC response: ${JSON.stringify(result).slice(0, 500)}`);
530
545
  this.stats.totalSpawnResponses++;
@@ -536,15 +551,15 @@ ${recentLines.join("\n")}`;
536
551
  if (Array.isArray(payloads) && payloads.length > 0) {
537
552
  const output = payloads.map((pl: any) => pl.text || "").join("\n").trim();
538
553
  if (output) {
539
- this.pushResponse(`${label || "Background task"}:\n${output}`);
540
- this.broadcastTaskEvent(taskId, "completed", label, startedAt, output);
554
+ this.pushResponse(`${safeLabel || "Background task"}:\n${output}`);
555
+ this.broadcastTaskEvent(taskId, "completed", safeLabel, startedAt, output);
541
556
  } else {
542
557
  log(TAG, `spawn-task: ${payloads.length} payloads but empty text, trying chat.history`);
543
558
  const historyText = await this.fetchChildResult(childSessionKey);
544
- this.broadcastTaskEvent(taskId, "completed", label, startedAt,
559
+ this.broadcastTaskEvent(taskId, "completed", safeLabel, startedAt,
545
560
  historyText || "task completed (no output)");
546
561
  if (historyText) {
547
- this.pushResponse(`${label || "Background task"}:\n${historyText}`);
562
+ this.pushResponse(`${safeLabel || "Background task"}:\n${historyText}`);
548
563
  }
549
564
  }
550
565
  } else {
@@ -552,10 +567,10 @@ ${recentLines.join("\n")}`;
552
567
  log(TAG, `spawn-task: no payloads, fetching chat.history for child=${childSessionKey}`);
553
568
  const historyText = await this.fetchChildResult(childSessionKey);
554
569
  if (historyText) {
555
- this.pushResponse(`${label || "Background task"}:\n${historyText}`);
556
- this.broadcastTaskEvent(taskId, "completed", label, startedAt, historyText);
570
+ this.pushResponse(`${safeLabel || "Background task"}:\n${historyText}`);
571
+ this.broadcastTaskEvent(taskId, "completed", safeLabel, startedAt, historyText);
557
572
  } else {
558
- this.broadcastTaskEvent(taskId, "completed", label, startedAt,
573
+ this.broadcastTaskEvent(taskId, "completed", safeLabel, startedAt,
559
574
  "task completed (no output captured)");
560
575
  }
561
576
  }
@@ -564,7 +579,7 @@ ${recentLines.join("\n")}`;
564
579
  this.pendingSpawnTasks.set(taskId, {
565
580
  runId,
566
581
  childSessionKey,
567
- label,
582
+ label: safeLabel,
568
583
  startedAt,
569
584
  pollingEmitted: false,
570
585
  });
@@ -575,30 +590,43 @@ ${recentLines.join("\n")}`;
575
590
  savePendingTasks(this.pendingSpawnTasks);
576
591
  } catch (err: any) {
577
592
  error(TAG, `spawn-task failed: ${err.message}`);
578
- this.broadcastTaskEvent(taskId, "failed", label, startedAt);
593
+ this.broadcastTaskEvent(taskId, "failed", safeLabel, startedAt);
579
594
  } finally {
580
595
  this.spawnInFlight = false;
581
596
  }
582
597
  }
583
598
 
584
- /** Build a focused system prompt for the child subagent. */
585
- private buildChildSystemPrompt(task: string, label?: string): string {
586
- return [
587
- "# Subagent Context",
588
- "",
589
- "You are a **subagent** spawned for a specific task.",
599
+ /** Build a context-rich system prompt for the child subagent. */
600
+ private buildChildSystemPrompt(context?: SpawnContext): string {
601
+ const parts = [
602
+ "# Background Agent",
590
603
  "",
591
- "## Your Role",
592
- `- Task: ${task.replace(/\s+/g, " ").trim().slice(0, 500)}`,
593
- "- Complete this task. That's your entire purpose.",
604
+ "You are a background agent spawned by the user to complete a specific task.",
605
+ "You have full tool access: file operations, web search, code execution.",
606
+ "Create end-to-end valuable artifacts summaries, code files, emails, analysis docs.",
594
607
  "",
595
608
  "## Rules",
596
- "1. Stay focused — do your assigned task, nothing else",
597
- "2. Your final message will be reported to the requester",
598
- "3. Be concise but informative",
599
- "",
600
- label ? `Label: ${label}` : "",
601
- ].filter(Boolean).join("\n");
609
+ "1. Complete the task fully actually do it, don't just describe what you'd do",
610
+ "2. Use your tools: search the web, write files, run code as needed",
611
+ "3. Your final message is shown in a small overlay — keep it concise (1-3 sentences + key links/paths)",
612
+ "4. For substantial output, write to a file and report the path",
613
+ ];
614
+
615
+ if (context?.currentApp || context?.digest) {
616
+ parts.push("", "## User Context");
617
+ if (context.currentApp) parts.push(`- Current app: ${context.currentApp}`);
618
+ if (context.digest) parts.push(`- Situation: ${context.digest.slice(0, 500)}`);
619
+ }
620
+
621
+ if (context?.recentScreen) {
622
+ parts.push("", "## Recent Screen (OCR, last ~60s)", context.recentScreen);
623
+ }
624
+
625
+ if (context?.recentAudio) {
626
+ parts.push("", "## Recent Audio (last ~60s)", context.recentAudio);
627
+ }
628
+
629
+ return parts.join("\n");
602
630
  }
603
631
 
604
632
  /** Fetch the latest assistant reply from a child session's chat history. */
@@ -10,7 +10,7 @@ import { TranscriptionService } from "./audio/transcription.js";
10
10
  import { AgentLoop } from "./agent/loop.js";
11
11
  import { TraitEngine, loadTraitRoster } from "./agent/traits.js";
12
12
  import { shortAppName } from "./agent/context-window.js";
13
- import { Escalator } from "./escalation/escalator.js";
13
+ import { Escalator, type SpawnContext } from "./escalation/escalator.js";
14
14
  import { Recorder } from "./recorder.js";
15
15
  import { Tracer } from "./trace/tracer.js";
16
16
  import { TraceStore } from "./trace/trace-store.js";
@@ -25,6 +25,41 @@ import { initPrivacy, levelFor, applyLevel } from "./privacy/index.js";
25
25
 
26
26
  const TAG = "core";
27
27
 
28
+ /** Build context snapshot for user-initiated spawn tasks. */
29
+ function buildSpawnContext(
30
+ entry: { digest: string; context: { currentApp: string } },
31
+ feedBuffer: FeedBuffer,
32
+ senseBuffer: SenseBuffer,
33
+ ): SpawnContext {
34
+ const cutoff = Date.now() - 60_000;
35
+
36
+ // Recent audio: last ~60s of transcripts
37
+ const recentAudio = feedBuffer.queryByTime(cutoff)
38
+ .filter(m => m.channel === "stream" && (m.text.startsWith("[🔊]") || m.text.startsWith("[🎙]")))
39
+ .map(m => m.text)
40
+ .join("\n")
41
+ .slice(0, 2000);
42
+
43
+ // Recent screen: last ~60s of deduped OCR text
44
+ const screenEvents = senseBuffer.queryByTime(cutoff);
45
+ const seenOcr = new Set<string>();
46
+ const screenLines: string[] = [];
47
+ for (const e of screenEvents) {
48
+ if (e.ocr && !seenOcr.has(e.ocr)) {
49
+ seenOcr.add(e.ocr);
50
+ screenLines.push(`[${e.meta.app}] ${e.ocr}`);
51
+ }
52
+ }
53
+ const recentScreen = screenLines.join("\n").slice(0, 3000);
54
+
55
+ return {
56
+ currentApp: entry.context.currentApp,
57
+ digest: entry.digest,
58
+ recentAudio: recentAudio || undefined,
59
+ recentScreen: recentScreen || undefined,
60
+ };
61
+ }
62
+
28
63
  /** Resolve workspace path, expanding leading ~ to HOME. */
29
64
  function resolveWorkspace(): string {
30
65
  const raw = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
@@ -65,6 +100,9 @@ async function main() {
65
100
  // ── Initialize recorder ──
66
101
  const recorder = new Recorder();
67
102
 
103
+ // ── Spawn context cache — updated every agent tick for user-initiated spawns ──
104
+ let lastSpawnContext: SpawnContext | null = null;
105
+
68
106
  // ── Initialize profiler ──
69
107
  const profiler = new Profiler();
70
108
 
@@ -108,35 +146,11 @@ async function main() {
108
146
  getRecorderStatus: () => recorder.getStatus(),
109
147
  profiler,
110
148
  onAnalysis: (entry, contextWindow) => {
111
- // Handle recorder commands
112
- const stopResult = recorder.handleCommand(entry.record);
113
-
114
- // Dispatch task via subagent spawn
115
- if (entry.task || stopResult) {
116
- let task: string;
117
- let label: string | undefined;
118
-
119
- if (stopResult && stopResult.segments > 0 && entry.task) {
120
- // Recording stopped with explicit task instruction
121
- task = `${entry.task}\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
122
- label = stopResult.title;
123
- } else if (stopResult && stopResult.segments > 0) {
124
- // Recording stopped without explicit task — default to cleanup/summarize
125
- task = `Clean up and summarize this recording transcript:\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
126
- label = stopResult.title;
127
- } else if (entry.task) {
128
- // Standalone task without recording
129
- task = entry.task;
130
- } else {
131
- task = "";
132
- }
149
+ // Handle recorder commands (recording start/stop still works)
150
+ recorder.handleCommand(entry.record);
133
151
 
134
- if (task) {
135
- escalator.dispatchSpawnTask(task, label).catch(err => {
136
- error(TAG, "spawn task dispatch error:", err);
137
- });
138
- }
139
- }
152
+ // Cache context for user-initiated spawn commands (Shift+Enter)
153
+ lastSpawnContext = buildSpawnContext(entry, feedBuffer, senseBuffer);
140
154
 
141
155
  // Escalation continues as normal
142
156
  escalator.onAgentAnalysis(entry, contextWindow);
@@ -433,7 +447,7 @@ async function main() {
433
447
 
434
448
  // Spawn background agent task (from HUD Shift+Enter or bare agent POST /spawn)
435
449
  onSpawnCommand: (text: string) => {
436
- escalator.dispatchSpawnTask(text, "user-command").catch((err) => {
450
+ escalator.dispatchSpawnTask(text, text.slice(0, 64), lastSpawnContext ?? undefined).catch((err) => {
437
451
  log("srv", `spawn via HTTP failed: ${err}`);
438
452
  });
439
453
  },
@@ -457,9 +471,11 @@ async function main() {
457
471
  },
458
472
  onUserCommand: (text) => {
459
473
  escalator.setUserCommand(text);
474
+ // Trigger agent loop immediately for user commands (bypass debounce + cooldown)
475
+ agentLoop.onNewContext(true);
460
476
  },
461
477
  onSpawnCommand: (text) => {
462
- escalator.dispatchSpawnTask(text, "user-command").catch((err) => {
478
+ escalator.dispatchSpawnTask(text, text.slice(0, 64), lastSpawnContext ?? undefined).catch((err) => {
463
479
  log("cmd", `spawn command failed: ${err}`);
464
480
  wsHandler.broadcast(`\u26a0 Spawn failed: ${String(err).slice(0, 100)}`, "normal");
465
481
  });
@@ -60,6 +60,15 @@ export function setupCommands(deps: CommandDeps): void {
60
60
  case "spawn_command": {
61
61
  const preview = msg.text.length > 60 ? msg.text.slice(0, 60) + "…" : msg.text;
62
62
  log(TAG, `spawn command received: "${preview}"`);
63
+ // Echo spawn command to all overlay clients as a feed item (green in UI)
64
+ wsHandler.broadcastRaw({
65
+ type: "feed",
66
+ text: `⚡ ${msg.text}`,
67
+ priority: "normal",
68
+ ts: Date.now(),
69
+ channel: "agent",
70
+ sender: "spawn",
71
+ } as any);
63
72
  if (deps.onSpawnCommand) {
64
73
  deps.onSpawnCommand(msg.text);
65
74
  } else {
@@ -199,6 +199,9 @@ export class WsHandler {
199
199
  case "user_command":
200
200
  log(TAG, `\u2190 user command: ${msg.text.slice(0, 100)}`);
201
201
  break;
202
+ case "spawn_command":
203
+ log(TAG, `\u2190 spawn command: ${msg.text.slice(0, 100)}`);
204
+ break;
202
205
  case "profiling":
203
206
  if (this.onProfilingCb) this.onProfilingCb(msg);
204
207
  return;
@@ -1,23 +0,0 @@
1
- # sinain-agent configuration
2
- # Copy to .env and customize: cp .env.example .env
3
-
4
- # ── Agent ──
5
- SINAIN_AGENT=claude # claude | codex | junie | goose | aider | <custom command>
6
- # MCP agents (claude, codex, junie, goose) call sinain tools directly
7
- # Pipe agents (aider, custom) receive escalation text on stdin
8
-
9
- # ── Core connection ──
10
- SINAIN_CORE_URL=http://localhost:9500
11
-
12
- # ── Timing ──
13
- SINAIN_POLL_INTERVAL=5 # seconds between escalation polls
14
- SINAIN_HEARTBEAT_INTERVAL=900 # seconds between heartbeat ticks (15 min)
15
-
16
- # ── Workspace ──
17
- SINAIN_WORKSPACE=~/.openclaw/workspace # knowledge files, curation scripts, playbook
18
-
19
- # ── Tool permissions (Claude only) ──
20
- # Tools auto-approved without prompting (space-separated).
21
- # Default: auto-derived from MCP config server names (e.g. mcp__sinain).
22
- # Format: mcp__<server> (all tools) | mcp__<server>__<tool> (specific) | Bash(pattern)
23
- # SINAIN_ALLOWED_TOOLS=mcp__sinain mcp__github Bash(git *)