@geravant/sinain 1.3.0 → 1.5.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/cli.js CHANGED
@@ -6,6 +6,7 @@ import fs from "fs";
6
6
  import path from "path";
7
7
 
8
8
  const cmd = process.argv[2];
9
+ const IS_WINDOWS = os.platform() === "win32";
9
10
 
10
11
  switch (cmd) {
11
12
  case "start":
@@ -20,6 +21,10 @@ switch (cmd) {
20
21
  await showStatus();
21
22
  break;
22
23
 
24
+ case "setup":
25
+ await runSetupWizard();
26
+ break;
27
+
23
28
  case "setup-overlay":
24
29
  await import("./setup-overlay.js");
25
30
  break;
@@ -41,39 +46,209 @@ switch (cmd) {
41
46
  break;
42
47
  }
43
48
 
49
+ // ── Setup wizard (standalone) ─────────────────────────────────────────────────
50
+
51
+ async function runSetupWizard() {
52
+ // Force-run the wizard even if .env exists (re-configure)
53
+ const { setupWizard } = await import("./launcher.js?setup-only");
54
+ // The wizard is embedded in launcher.js; we import the module dynamically.
55
+ // Since launcher.js runs main() on import, we instead inline a lightweight version.
56
+
57
+ const readline = await import("readline");
58
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
59
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
60
+
61
+ const HOME = os.homedir();
62
+ const SINAIN_DIR = path.join(HOME, ".sinain");
63
+ const envPath = path.join(SINAIN_DIR, ".env");
64
+
65
+ const BOLD = "\x1b[1m";
66
+ const DIM = "\x1b[2m";
67
+ const GREEN = "\x1b[32m";
68
+ const YELLOW = "\x1b[33m";
69
+ const RESET = "\x1b[0m";
70
+ const IS_WIN = os.platform() === "win32";
71
+
72
+ const cmdExists = (cmd) => {
73
+ try { import("child_process").then(cp => cp.execSync(`which ${cmd}`, { stdio: "pipe" })); return true; }
74
+ catch { return false; }
75
+ };
76
+ // Synchronous version
77
+ const { execSync } = await import("child_process");
78
+ const cmdExistsSync = (cmd) => {
79
+ try { execSync(`which ${cmd}`, { stdio: "pipe" }); return true; }
80
+ catch { return false; }
81
+ };
82
+
83
+ if (fs.existsSync(envPath)) {
84
+ const overwrite = await ask(` ${envPath} already exists. Overwrite? [y/N]: `);
85
+ if (overwrite.trim().toLowerCase() !== "y") {
86
+ console.log(" Aborted.");
87
+ rl.close();
88
+ return;
89
+ }
90
+ }
91
+
92
+ console.log();
93
+ console.log(`${BOLD}── Sinain Setup Wizard ─────────────────${RESET}`);
94
+ console.log(` Configuring ${DIM}~/.sinain/.env${RESET}`);
95
+ console.log();
96
+
97
+ const vars = {};
98
+
99
+ // Transcription backend
100
+ let transcriptionBackend = "openrouter";
101
+ const hasWhisper = !IS_WIN && cmdExistsSync("whisper-cli");
102
+
103
+ if (IS_WIN) {
104
+ console.log(` ${DIM}(Local whisper not available on Windows — using OpenRouter)${RESET}`);
105
+ } else if (hasWhisper) {
106
+ const choice = await ask(` Transcription backend? [${BOLD}local${RESET}/cloud]: `);
107
+ transcriptionBackend = choice.trim().toLowerCase() === "cloud" ? "openrouter" : "local";
108
+ } else {
109
+ const install = await ask(` whisper-cli not found. Install via Homebrew? [Y/n]: `);
110
+ if (!install.trim() || install.trim().toLowerCase() === "y") {
111
+ try {
112
+ execSync("brew install whisper-cpp", { stdio: "inherit" });
113
+ const modelDir = path.join(HOME, "models");
114
+ const modelPath = path.join(modelDir, "ggml-large-v3-turbo.bin");
115
+ if (!fs.existsSync(modelPath)) {
116
+ console.log(` ${DIM}Downloading model (~1.5 GB)...${RESET}`);
117
+ fs.mkdirSync(modelDir, { recursive: true });
118
+ execSync(`curl -L --progress-bar -o "${modelPath}" "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin"`, { stdio: "inherit" });
119
+ }
120
+ transcriptionBackend = "local";
121
+ vars.LOCAL_WHISPER_MODEL = modelPath;
122
+ } catch {
123
+ console.log(` ${YELLOW}Install failed — falling back to OpenRouter${RESET}`);
124
+ }
125
+ }
126
+ }
127
+ vars.TRANSCRIPTION_BACKEND = transcriptionBackend;
128
+
129
+ // API key
130
+ if (transcriptionBackend === "openrouter") {
131
+ const key = await ask(` OpenRouter API key (sk-or-...): `);
132
+ if (key.trim()) vars.OPENROUTER_API_KEY = key.trim();
133
+ } else {
134
+ const key = await ask(` OpenRouter API key for vision/OCR (optional): `);
135
+ if (key.trim()) vars.OPENROUTER_API_KEY = key.trim();
136
+ }
137
+
138
+ // Agent
139
+ const agent = await ask(` Agent? [${BOLD}claude${RESET}/codex/goose/junie/aider]: `);
140
+ vars.SINAIN_AGENT = agent.trim().toLowerCase() || "claude";
141
+
142
+ // Escalation
143
+ console.log(`\n ${DIM}Escalation: off | selective | focus | rich${RESET}`);
144
+ const esc = await ask(` Escalation mode? [${BOLD}selective${RESET}]: `);
145
+ vars.ESCALATION_MODE = esc.trim().toLowerCase() || "selective";
146
+
147
+ // Gateway
148
+ const gw = await ask(` OpenClaw gateway? [y/N]: `);
149
+ if (gw.trim().toLowerCase() === "y") {
150
+ const url = await ask(` Gateway WS URL [ws://localhost:18789]: `);
151
+ vars.OPENCLAW_WS_URL = url.trim() || "ws://localhost:18789";
152
+ const token = await ask(` Auth token (48-char hex): `);
153
+ if (token.trim()) {
154
+ vars.OPENCLAW_WS_TOKEN = token.trim();
155
+ vars.OPENCLAW_HTTP_TOKEN = token.trim();
156
+ }
157
+ vars.OPENCLAW_HTTP_URL = vars.OPENCLAW_WS_URL.replace(/^ws/, "http") + "/hooks/agent";
158
+ vars.OPENCLAW_SESSION_KEY = "agent:main:sinain";
159
+ }
160
+
161
+ vars.SINAIN_POLL_INTERVAL = "5";
162
+ vars.SINAIN_HEARTBEAT_INTERVAL = "900";
163
+ vars.PRIVACY_MODE = "standard";
164
+
165
+ // Write — start from .env.example template, patch wizard values in
166
+ fs.mkdirSync(SINAIN_DIR, { recursive: true });
167
+
168
+ const PKG_DIR = path.dirname(new URL(import.meta.url).pathname);
169
+ const examplePath = path.join(PKG_DIR, ".env.example");
170
+ const siblingExample = path.join(PKG_DIR, "..", ".env.example");
171
+ let template = "";
172
+ if (fs.existsSync(examplePath)) {
173
+ template = fs.readFileSync(examplePath, "utf-8");
174
+ } else if (fs.existsSync(siblingExample)) {
175
+ template = fs.readFileSync(siblingExample, "utf-8");
176
+ }
177
+
178
+ if (template) {
179
+ for (const [k, v] of Object.entries(vars)) {
180
+ const regex = new RegExp(`^#?\\s*${k}=.*$`, "m");
181
+ if (regex.test(template)) {
182
+ template = template.replace(regex, `${k}=${v}`);
183
+ } else {
184
+ template += `\n${k}=${v}`;
185
+ }
186
+ }
187
+ template = `# Generated by sinain setup wizard — ${new Date().toISOString()}\n${template}`;
188
+ fs.writeFileSync(envPath, template);
189
+ } else {
190
+ const lines = ["# sinain configuration — generated by setup wizard", `# ${new Date().toISOString()}`, ""];
191
+ for (const [k, v] of Object.entries(vars)) lines.push(`${k}=${v}`);
192
+ lines.push("");
193
+ fs.writeFileSync(envPath, lines.join("\n"));
194
+ }
195
+
196
+ rl.close();
197
+ console.log(`\n ${GREEN}✓${RESET} Config written to ${envPath}\n`);
198
+ }
199
+
44
200
  // ── Stop ──────────────────────────────────────────────────────────────────────
45
201
 
46
202
  async function stopServices() {
47
203
  let killed = false;
48
204
 
49
- const patterns = [
50
- "tsx.*src/index.ts",
51
- "tsx watch src/index.ts",
52
- "python3 -m sense_client",
53
- "Python -m sense_client",
54
- "flutter run -d macos",
55
- "sinain_hud.app/Contents/MacOS/sinain_hud",
56
- "sinain-agent/run.sh",
57
- ];
58
-
59
- for (const pat of patterns) {
60
- try {
61
- execSync(`pkill -f "${pat}"`, { stdio: "pipe" });
62
- killed = true;
63
- } catch { /* not running */ }
205
+ if (IS_WINDOWS) {
206
+ const exes = ["sinain_hud.exe", "tsx.cmd", "python3.exe", "python.exe"];
207
+ for (const exe of exes) {
208
+ try {
209
+ execSync(`taskkill /F /IM "${exe}" 2>NUL`, { stdio: "pipe" });
210
+ killed = true;
211
+ } catch { /* not running */ }
212
+ }
213
+ } else {
214
+ const patterns = [
215
+ "tsx.*src/index.ts",
216
+ "tsx watch src/index.ts",
217
+ "python3 -m sense_client",
218
+ "Python -m sense_client",
219
+ "flutter run -d macos",
220
+ "sinain_hud.app/Contents/MacOS/sinain_hud",
221
+ "sinain-agent/run.sh",
222
+ ];
223
+
224
+ for (const pat of patterns) {
225
+ try {
226
+ execSync(`pkill -f "${pat}"`, { stdio: "pipe" });
227
+ killed = true;
228
+ } catch { /* not running */ }
229
+ }
64
230
  }
65
231
 
66
232
  // Free port 9500
67
233
  try {
68
- const pid = execSync("lsof -i :9500 -sTCP:LISTEN -t", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
69
- if (pid) {
70
- execSync(`kill ${pid}`, { stdio: "pipe" });
71
- killed = true;
234
+ if (IS_WINDOWS) {
235
+ const out = execSync('netstat -ano | findstr ":9500" | findstr "LISTENING"', { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
236
+ const pid = out.split(/\s+/).pop();
237
+ if (pid && pid !== "0") {
238
+ execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
239
+ killed = true;
240
+ }
241
+ } else {
242
+ const pid = execSync("lsof -i :9500 -sTCP:LISTEN -t", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
243
+ if (pid) {
244
+ execSync(`kill ${pid}`, { stdio: "pipe" });
245
+ killed = true;
246
+ }
72
247
  }
73
248
  } catch { /* port already free */ }
74
249
 
75
250
  // Clean PID file
76
- const pidFile = "/tmp/sinain-pids.txt";
251
+ const pidFile = path.join(os.tmpdir(), "sinain-pids.txt");
77
252
  if (fs.existsSync(pidFile)) {
78
253
  fs.unlinkSync(pidFile);
79
254
  }
@@ -107,7 +282,7 @@ async function showStatus() {
107
282
  console.log(` ${CYAN}core${RESET} :9500 ${RED}✗${RESET} stopped`);
108
283
  }
109
284
 
110
- // Sense: check pgrep
285
+ // Sense: check process
111
286
  const senseUp = isProcessRunning("python3 -m sense_client") || isProcessRunning("Python -m sense_client");
112
287
  if (senseUp) {
113
288
  console.log(` ${YELLOW}sense${RESET} ${GREEN}✓${RESET} running`);
@@ -116,7 +291,7 @@ async function showStatus() {
116
291
  }
117
292
 
118
293
  // Overlay
119
- const overlayUp = isProcessRunning("sinain_hud.app") || isProcessRunning("flutter run -d macos");
294
+ const overlayUp = isProcessRunning("sinain_hud");
120
295
  if (overlayUp) {
121
296
  console.log(` ${MAGENTA}overlay${RESET} ${GREEN}✓${RESET} running`);
122
297
  } else {
@@ -124,7 +299,7 @@ async function showStatus() {
124
299
  }
125
300
 
126
301
  // Agent
127
- const agentUp = isProcessRunning("sinain-agent/run.sh");
302
+ const agentUp = isProcessRunning("sinain-agent");
128
303
  if (agentUp) {
129
304
  console.log(` ${GREEN}agent${RESET} ${GREEN}✓${RESET} running`);
130
305
  } else {
@@ -147,8 +322,13 @@ function isPortOpen(port) {
147
322
 
148
323
  function isProcessRunning(pattern) {
149
324
  try {
150
- execSync(`pgrep -f "${pattern}"`, { stdio: "pipe" });
151
- return true;
325
+ if (IS_WINDOWS) {
326
+ const out = execSync(`tasklist /FI "IMAGENAME eq ${pattern}.exe" 2>NUL`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
327
+ return out.includes(pattern);
328
+ } else {
329
+ execSync(`pgrep -f "${pattern}"`, { stdio: "pipe" });
330
+ return true;
331
+ }
152
332
  } catch {
153
333
  return false;
154
334
  }
@@ -158,12 +338,13 @@ function isProcessRunning(pattern) {
158
338
 
159
339
  function printUsage() {
160
340
  console.log(`
161
- sinain — AI overlay system for macOS
341
+ sinain — AI overlay system for macOS and Windows
162
342
 
163
343
  Usage:
164
344
  sinain start [options] Launch sinain services
165
345
  sinain stop Stop all sinain services
166
346
  sinain status Check what's running
347
+ sinain setup Run interactive setup wizard (~/.sinain/.env)
167
348
  sinain setup-overlay Download pre-built overlay app
168
349
  sinain install Install OpenClaw plugin (server-side)
169
350
 
package/launcher.js CHANGED
@@ -25,7 +25,8 @@ const RESET = "\x1b[0m";
25
25
  const PKG_DIR = path.dirname(new URL(import.meta.url).pathname);
26
26
  const HOME = os.homedir();
27
27
  const SINAIN_DIR = path.join(HOME, ".sinain");
28
- const PID_FILE = "/tmp/sinain-pids.txt";
28
+ const PID_FILE = path.join(os.tmpdir(), "sinain-pids.txt");
29
+ const IS_WINDOWS = os.platform() === "win32";
29
30
 
30
31
  // ── Parse flags ─────────────────────────────────────────────────────────────
31
32
 
@@ -59,6 +60,12 @@ async function main() {
59
60
  await preflight();
60
61
  console.log();
61
62
 
63
+ // Run setup wizard on first launch (no ~/.sinain/.env)
64
+ const userEnvPath = path.join(SINAIN_DIR, ".env");
65
+ if (!fs.existsSync(userEnvPath)) {
66
+ await setupWizard(userEnvPath);
67
+ }
68
+
62
69
  // Load user config
63
70
  loadUserEnv();
64
71
 
@@ -141,16 +148,20 @@ async function main() {
141
148
  if (!skipOverlay) {
142
149
  const overlay = findOverlay();
143
150
  if (overlay?.type === "prebuilt") {
144
- // Remove quarantine if present (ad-hoc signed app)
145
- try {
146
- const xattrs = execSync(`xattr "${overlay.path}"`, { encoding: "utf-8" });
147
- if (xattrs.includes("com.apple.quarantine")) {
148
- execSync(`xattr -dr com.apple.quarantine "${overlay.path}"`, { stdio: "pipe" });
149
- }
150
- } catch { /* no quarantine or xattr failed — try launching anyway */ }
151
+ // Remove macOS quarantine if present (ad-hoc signed app)
152
+ if (!IS_WINDOWS) {
153
+ try {
154
+ const xattrs = execSync(`xattr "${overlay.path}"`, { encoding: "utf-8" });
155
+ if (xattrs.includes("com.apple.quarantine")) {
156
+ execSync(`xattr -dr com.apple.quarantine "${overlay.path}"`, { stdio: "pipe" });
157
+ }
158
+ } catch { /* no quarantine or xattr failed — try launching anyway */ }
159
+ }
151
160
 
152
161
  log("Starting overlay (pre-built)...");
153
- const binary = path.join(overlay.path, "Contents/MacOS/sinain_hud");
162
+ const binary = IS_WINDOWS
163
+ ? overlay.path // sinain_hud.exe
164
+ : path.join(overlay.path, "Contents/MacOS/sinain_hud");
154
165
  startProcess("overlay", binary, [], { color: MAGENTA });
155
166
  await sleep(2000);
156
167
  const overlayChild = children.find(c => c.name === "overlay");
@@ -165,7 +176,8 @@ async function main() {
165
176
  const hasFlutter = commandExists("flutter");
166
177
  if (hasFlutter) {
167
178
  log("Starting overlay (flutter run)...");
168
- startProcess("overlay", "flutter", ["run", "-d", "macos"], {
179
+ const device = IS_WINDOWS ? "windows" : "macos";
180
+ startProcess("overlay", "flutter", ["run", "-d", device], {
169
181
  cwd: overlay.path,
170
182
  color: MAGENTA,
171
183
  });
@@ -259,7 +271,8 @@ async function preflight() {
259
271
  ok("flutter (version unknown)");
260
272
  }
261
273
  } else {
262
- const prebuiltApp = path.join(SINAIN_DIR, "overlay-app", "sinain_hud.app");
274
+ const prebuiltName = IS_WINDOWS ? "sinain_hud.exe" : "sinain_hud.app";
275
+ const prebuiltApp = path.join(SINAIN_DIR, "overlay-app", prebuiltName);
263
276
  if (fs.existsSync(prebuiltApp)) {
264
277
  ok("overlay: pre-built app");
265
278
  } else {
@@ -278,12 +291,179 @@ async function preflight() {
278
291
  }
279
292
  }
280
293
 
294
+ // ── Setup wizard ─────────────────────────────────────────────────────────────
295
+
296
+ async function setupWizard(envPath) {
297
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
298
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
299
+
300
+ console.log();
301
+ console.log(`${BOLD}── First-time setup ────────────────────${RESET}`);
302
+ console.log(` Configuring ${DIM}~/.sinain/.env${RESET}`);
303
+ console.log();
304
+
305
+ const vars = {};
306
+
307
+ // 1. Transcription backend — auto-detect whisper-cli
308
+ let transcriptionBackend = "openrouter";
309
+ const hasWhisper = !IS_WINDOWS && commandExists("whisper-cli");
310
+
311
+ if (IS_WINDOWS) {
312
+ console.log(` ${DIM}(Local whisper not available on Windows — using OpenRouter)${RESET}`);
313
+ } else if (hasWhisper) {
314
+ const choice = await ask(` Transcription backend? [${BOLD}local${RESET}/cloud] (local = whisper-cli, no API key): `);
315
+ if (choice.trim().toLowerCase() === "cloud") {
316
+ transcriptionBackend = "openrouter";
317
+ } else {
318
+ transcriptionBackend = "local";
319
+ }
320
+ } else {
321
+ const installWhisper = await ask(` whisper-cli not found. Install via Homebrew? [Y/n]: `);
322
+ if (!installWhisper.trim() || installWhisper.trim().toLowerCase() === "y") {
323
+ try {
324
+ console.log(` ${DIM}Installing whisper-cpp...${RESET}`);
325
+ execSync("brew install whisper-cpp", { stdio: "inherit" });
326
+
327
+ // Download model
328
+ const modelDir = path.join(HOME, "models");
329
+ const modelPath = path.join(modelDir, "ggml-large-v3-turbo.bin");
330
+ if (!fs.existsSync(modelPath)) {
331
+ console.log(` ${DIM}Downloading ggml-large-v3-turbo (~1.5 GB)...${RESET}`);
332
+ fs.mkdirSync(modelDir, { recursive: true });
333
+ execSync(
334
+ `curl -L --progress-bar -o "${modelPath}" "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin"`,
335
+ { stdio: "inherit" }
336
+ );
337
+ }
338
+
339
+ transcriptionBackend = "local";
340
+ vars.LOCAL_WHISPER_MODEL = modelPath;
341
+ ok("whisper-cpp installed");
342
+ } catch {
343
+ warn("whisper-cpp install failed — falling back to OpenRouter");
344
+ transcriptionBackend = "openrouter";
345
+ }
346
+ } else {
347
+ transcriptionBackend = "openrouter";
348
+ }
349
+ }
350
+ vars.TRANSCRIPTION_BACKEND = transcriptionBackend;
351
+
352
+ // 2. OpenRouter API key (if cloud backend or for vision/OCR)
353
+ if (transcriptionBackend === "openrouter") {
354
+ let key = "";
355
+ while (!key) {
356
+ key = await ask(` OpenRouter API key (sk-or-...): `);
357
+ key = key.trim();
358
+ if (key && !key.startsWith("sk-or-")) {
359
+ console.log(` ${YELLOW}⚠${RESET} Key should start with sk-or-. Try again or press Enter to skip.`);
360
+ const retry = await ask(` Use this key anyway? [y/N]: `);
361
+ if (retry.trim().toLowerCase() !== "y") { key = ""; continue; }
362
+ }
363
+ if (!key) {
364
+ console.log(` ${DIM}You can set OPENROUTER_API_KEY later in ~/.sinain/.env${RESET}`);
365
+ break;
366
+ }
367
+ }
368
+ if (key) vars.OPENROUTER_API_KEY = key;
369
+ } else {
370
+ // Still ask for OpenRouter key (needed for vision/OCR)
371
+ const key = await ask(` OpenRouter API key for vision/OCR (optional, Enter to skip): `);
372
+ if (key.trim()) vars.OPENROUTER_API_KEY = key.trim();
373
+ }
374
+
375
+ // 3. Agent selection
376
+ const agentChoice = await ask(` Agent? [${BOLD}claude${RESET}/codex/goose/junie/aider]: `);
377
+ vars.SINAIN_AGENT = agentChoice.trim().toLowerCase() || "claude";
378
+
379
+ // 4. Escalation mode
380
+ console.log();
381
+ console.log(` ${DIM}Escalation modes:${RESET}`);
382
+ console.log(` off — no escalation to gateway`);
383
+ console.log(` selective — score-based (errors, questions trigger it)`);
384
+ console.log(` focus — always escalate every tick`);
385
+ console.log(` rich — always escalate with maximum context`);
386
+ const escMode = await ask(` Escalation mode? [off/${BOLD}selective${RESET}/focus/rich]: `);
387
+ vars.ESCALATION_MODE = escMode.trim().toLowerCase() || "selective";
388
+
389
+ // 5. OpenClaw gateway
390
+ const hasGateway = await ask(` Do you have an OpenClaw gateway? [y/N]: `);
391
+ if (hasGateway.trim().toLowerCase() === "y") {
392
+ const wsUrl = await ask(` Gateway WebSocket URL [ws://localhost:18789]: `);
393
+ vars.OPENCLAW_WS_URL = wsUrl.trim() || "ws://localhost:18789";
394
+
395
+ const wsToken = await ask(` Gateway auth token (48-char hex): `);
396
+ if (wsToken.trim()) {
397
+ vars.OPENCLAW_WS_TOKEN = wsToken.trim();
398
+ vars.OPENCLAW_HTTP_TOKEN = wsToken.trim();
399
+ }
400
+
401
+ // Derive HTTP URL from WS URL
402
+ const httpBase = vars.OPENCLAW_WS_URL.replace(/^ws/, "http");
403
+ vars.OPENCLAW_HTTP_URL = `${httpBase}/hooks/agent`;
404
+ vars.OPENCLAW_SESSION_KEY = "agent:main:sinain";
405
+ }
406
+
407
+ // 6. Agent-specific defaults
408
+ vars.SINAIN_POLL_INTERVAL = "5";
409
+ vars.SINAIN_HEARTBEAT_INTERVAL = "900";
410
+ vars.PRIVACY_MODE = "standard";
411
+
412
+ // Write .env — start from .env.example template, patch wizard values in
413
+ fs.mkdirSync(path.dirname(envPath), { recursive: true });
414
+
415
+ const examplePath = path.join(PKG_DIR, ".env.example");
416
+ let template = "";
417
+ if (fs.existsSync(examplePath)) {
418
+ template = fs.readFileSync(examplePath, "utf-8");
419
+ } else {
420
+ // Fallback: try sibling (running from cloned repo)
421
+ const siblingExample = path.join(PKG_DIR, "..", ".env.example");
422
+ if (fs.existsSync(siblingExample)) {
423
+ template = fs.readFileSync(siblingExample, "utf-8");
424
+ }
425
+ }
426
+
427
+ if (template) {
428
+ // Patch each wizard var into the template by replacing the KEY=... line
429
+ for (const [key, val] of Object.entries(vars)) {
430
+ // Match KEY=anything (possibly commented out with #)
431
+ const regex = new RegExp(`^#?\\s*${key}=.*$`, "m");
432
+ if (regex.test(template)) {
433
+ template = template.replace(regex, `${key}=${val}`);
434
+ } else {
435
+ // Key not in template — append it
436
+ template += `\n${key}=${val}`;
437
+ }
438
+ }
439
+ // Add wizard timestamp header
440
+ template = `# Generated by sinain setup wizard — ${new Date().toISOString()}\n${template}`;
441
+ fs.writeFileSync(envPath, template);
442
+ } else {
443
+ // No template found — write bare vars (fallback)
444
+ const lines = [];
445
+ lines.push("# sinain configuration — generated by setup wizard");
446
+ lines.push(`# ${new Date().toISOString()}`);
447
+ lines.push("");
448
+ for (const [key, val] of Object.entries(vars)) {
449
+ lines.push(`${key}=${val}`);
450
+ }
451
+ lines.push("");
452
+ fs.writeFileSync(envPath, lines.join("\n"));
453
+ }
454
+
455
+ rl.close();
456
+
457
+ console.log();
458
+ ok(`Config written to ${envPath}`);
459
+ console.log();
460
+ }
461
+
281
462
  // ── User environment ────────────────────────────────────────────────────────
282
463
 
283
464
  function loadUserEnv() {
284
465
  const envPaths = [
285
466
  path.join(SINAIN_DIR, ".env"),
286
- path.join(PKG_DIR, "sinain-core/.env"),
287
467
  ];
288
468
 
289
469
  for (const envPath of envPaths) {
@@ -373,31 +553,51 @@ async function installDeps() {
373
553
 
374
554
  function killStale() {
375
555
  let killed = false;
376
- const patterns = [
377
- "sinain_hud.app/Contents/MacOS/sinain_hud",
378
- "flutter run -d macos",
379
- "python3 -m sense_client",
380
- "Python -m sense_client",
381
- "tsx.*src/index.ts",
382
- "tsx watch src/index.ts",
383
- "sinain-agent/run.sh",
384
- ];
385
556
 
386
- for (const pat of patterns) {
557
+ if (IS_WINDOWS) {
558
+ const exes = ["sinain_hud.exe", "tsx.cmd"];
559
+ for (const exe of exes) {
560
+ try {
561
+ execSync(`taskkill /F /IM "${exe}" 2>NUL`, { stdio: "pipe" });
562
+ killed = true;
563
+ } catch { /* not running */ }
564
+ }
565
+ // Free port 9500
387
566
  try {
388
- execSync(`pkill -f "${pat}"`, { stdio: "pipe" });
389
- killed = true;
390
- } catch { /* not running */ }
391
- }
392
-
393
- // Free port 9500
394
- try {
395
- const pid = execSync("lsof -i :9500 -sTCP:LISTEN -t", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
396
- if (pid) {
397
- execSync(`kill ${pid}`, { stdio: "pipe" });
398
- killed = true;
567
+ const out = execSync('netstat -ano | findstr ":9500" | findstr "LISTENING"', { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
568
+ const pid = out.split(/\s+/).pop();
569
+ if (pid && pid !== "0") {
570
+ execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
571
+ killed = true;
572
+ }
573
+ } catch { /* already free */ }
574
+ } else {
575
+ const patterns = [
576
+ "sinain_hud.app/Contents/MacOS/sinain_hud",
577
+ "flutter run -d macos",
578
+ "python3 -m sense_client",
579
+ "Python -m sense_client",
580
+ "tsx.*src/index.ts",
581
+ "tsx watch src/index.ts",
582
+ "sinain-agent/run.sh",
583
+ ];
584
+
585
+ for (const pat of patterns) {
586
+ try {
587
+ execSync(`pkill -f "${pat}"`, { stdio: "pipe" });
588
+ killed = true;
589
+ } catch { /* not running */ }
399
590
  }
400
- } catch { /* already free */ }
591
+
592
+ // Free port 9500
593
+ try {
594
+ const pid = execSync("lsof -i :9500 -sTCP:LISTEN -t", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
595
+ if (pid) {
596
+ execSync(`kill ${pid}`, { stdio: "pipe" });
597
+ killed = true;
598
+ }
599
+ } catch { /* already free */ }
600
+ }
401
601
 
402
602
  // Clean old PID file
403
603
  if (fs.existsSync(PID_FILE)) {
@@ -496,8 +696,9 @@ function findOverlay() {
496
696
  return { type: "source", path: siblingOverlay };
497
697
  }
498
698
 
499
- // 2. Pre-built .app bundle (downloaded by setup-overlay)
500
- const prebuiltApp = path.join(SINAIN_DIR, "overlay-app", "sinain_hud.app");
699
+ // 2. Pre-built app (downloaded by setup-overlay)
700
+ const prebuiltName = IS_WINDOWS ? "sinain_hud.exe" : "sinain_hud.app";
701
+ const prebuiltApp = path.join(SINAIN_DIR, "overlay-app", prebuiltName);
501
702
  if (fs.existsSync(prebuiltApp)) {
502
703
  return { type: "prebuilt", path: prebuiltApp };
503
704
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Ambient AI overlay invisible to screen capture — real-time insights from audio + screen context",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "sinain-core/package.json",
27
27
  "sinain-core/package-lock.json",
28
28
  "sinain-core/tsconfig.json",
29
- "sinain-core/.env.example",
29
+ ".env.example",
30
30
  "sinain-mcp-server/index.ts",
31
31
  "sinain-mcp-server/package.json",
32
32
  "sinain-mcp-server/tsconfig.json",
package/setup-overlay.js CHANGED
@@ -9,8 +9,14 @@ import os from "os";
9
9
  const HOME = os.homedir();
10
10
  const SINAIN_DIR = path.join(HOME, ".sinain");
11
11
  const APP_DIR = path.join(SINAIN_DIR, "overlay-app");
12
- const APP_PATH = path.join(APP_DIR, "sinain_hud.app");
13
12
  const VERSION_FILE = path.join(APP_DIR, "version.json");
13
+ const IS_WINDOWS = os.platform() === "win32";
14
+
15
+ // Platform-specific asset and app path
16
+ const ASSET_NAME = IS_WINDOWS ? "sinain_hud_windows.zip" : "sinain_hud.app.zip";
17
+ const APP_PATH = IS_WINDOWS
18
+ ? path.join(APP_DIR, "sinain_hud.exe")
19
+ : path.join(APP_DIR, "sinain_hud.app");
14
20
 
15
21
  const REPO = "anthillnet/sinain-hud";
16
22
  const RELEASES_API = `https://api.github.com/repos/${REPO}/releases`;
@@ -79,15 +85,15 @@ async function downloadPrebuilt() {
79
85
  } catch { /* corrupt version file — re-download */ }
80
86
  }
81
87
 
82
- // Find the .zip asset
83
- const zipAsset = release.assets?.find(a => a.name === "sinain_hud.app.zip");
88
+ // Find the .zip asset for this platform
89
+ const zipAsset = release.assets?.find(a => a.name === ASSET_NAME);
84
90
  if (!zipAsset) {
85
- fail(`Release ${tag} has no sinain_hud.app.zip asset.\n Try: sinain setup-overlay --from-source`);
91
+ fail(`Release ${tag} has no ${ASSET_NAME} asset.\n Try: sinain setup-overlay --from-source`);
86
92
  }
87
93
 
88
94
  // Download with progress
89
- log(`Downloading overlay ${version} (${formatBytes(zipAsset.size)})...`);
90
- const zipPath = path.join(APP_DIR, "sinain_hud.app.zip");
95
+ log(`Downloading overlay ${version} for ${IS_WINDOWS ? "Windows" : "macOS"} (${formatBytes(zipAsset.size)})...`);
96
+ const zipPath = path.join(APP_DIR, ASSET_NAME);
91
97
 
92
98
  try {
93
99
  const res = await fetch(zipAsset.browser_download_url, {
@@ -125,23 +131,34 @@ async function downloadPrebuilt() {
125
131
  fs.rmSync(APP_PATH, { recursive: true, force: true });
126
132
  }
127
133
 
128
- // Extract — ditto preserves macOS extended attributes (critical for code signing)
134
+ // Extract
129
135
  log("Extracting...");
130
- try {
131
- execSync(`ditto -x -k "${zipPath}" "${APP_DIR}"`, { stdio: "pipe" });
132
- } catch {
133
- // Fallback to unzip
136
+ if (IS_WINDOWS) {
134
137
  try {
135
- execSync(`unzip -o -q "${zipPath}" -d "${APP_DIR}"`, { stdio: "pipe" });
138
+ execSync(
139
+ `powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${APP_DIR}' -Force"`,
140
+ { stdio: "pipe" }
141
+ );
136
142
  } catch (e) {
137
143
  fail(`Extraction failed: ${e.message}`);
138
144
  }
139
- }
145
+ } else {
146
+ // ditto preserves macOS extended attributes (critical for code signing)
147
+ try {
148
+ execSync(`ditto -x -k "${zipPath}" "${APP_DIR}"`, { stdio: "pipe" });
149
+ } catch {
150
+ try {
151
+ execSync(`unzip -o -q "${zipPath}" -d "${APP_DIR}"`, { stdio: "pipe" });
152
+ } catch (e) {
153
+ fail(`Extraction failed: ${e.message}`);
154
+ }
155
+ }
140
156
 
141
- // Remove quarantine attribute (ad-hoc signed app downloaded from internet)
142
- try {
143
- execSync(`xattr -cr "${APP_PATH}"`, { stdio: "pipe" });
144
- } catch { /* xattr may not be needed */ }
157
+ // Remove quarantine attribute (ad-hoc signed app downloaded from internet)
158
+ try {
159
+ execSync(`xattr -cr "${APP_PATH}"`, { stdio: "pipe" });
160
+ } catch { /* xattr may not be needed */ }
161
+ }
145
162
 
146
163
  // Write version marker
147
164
  fs.writeFileSync(VERSION_FILE, JSON.stringify({
@@ -203,8 +220,9 @@ async function buildFromSource() {
203
220
  log("Installing Flutter dependencies...");
204
221
  execSync("flutter pub get", { cwd: overlayDir, stdio: "inherit" });
205
222
 
206
- log("Building overlay (this may take a few minutes)...");
207
- execSync("flutter build macos", { cwd: overlayDir, stdio: "inherit" });
223
+ const buildTarget = IS_WINDOWS ? "windows" : "macos";
224
+ log(`Building overlay for ${buildTarget} (this may take a few minutes)...`);
225
+ execSync(`flutter build ${buildTarget}`, { cwd: overlayDir, stdio: "inherit" });
208
226
  ok("Overlay built successfully");
209
227
 
210
228
  // Symlink ~/.sinain/overlay → the overlay source dir
@@ -221,7 +239,7 @@ async function buildFromSource() {
221
239
  console.log(`
222
240
  ${GREEN}✓${RESET} Overlay setup complete!
223
241
  The overlay will auto-start with: ${BOLD}sinain start${RESET}
224
- Or run manually: cd ${overlayDir} && flutter run -d macos
242
+ Or run manually: cd ${overlayDir} && flutter run -d ${IS_WINDOWS ? "windows" : "macos"}
225
243
  `);
226
244
  }
227
245
 
@@ -82,6 +82,6 @@ Your working memory lives at `~/.openclaw/workspace/memory/`:
82
82
 
83
83
  ## Privacy
84
84
 
85
- The HUD overlay is invisible to screen capture. All content you receive has already been privacy-stripped by sinain-core. Your responses appear only on the ghost overlay — they are never captured in screenshots or recordings.
85
+ The HUD overlay is invisible to screen capture. All content you receive has already been privacy-stripped by sinain-core. Your responses appear only on the invisible overlay — they are never captured in screenshots or recordings.
86
86
 
87
87
  Never include `<private>` tagged content in your responses — it will be stripped automatically, but avoid echoing it.
@@ -3,12 +3,19 @@ set -euo pipefail
3
3
 
4
4
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
5
 
6
- # Load .env if present (does not override existing env vars)
6
+ # Load .env as fallback does NOT override vars already in the environment
7
+ # (e.g. vars set by the launcher from ~/.sinain/.env)
7
8
  if [ -f "$SCRIPT_DIR/.env" ]; then
8
- set -a
9
- # shellcheck source=/dev/null
10
- . "$SCRIPT_DIR/.env"
11
- set +a
9
+ while IFS='=' read -r key val; do
10
+ # Skip comments and blank lines
11
+ [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
12
+ key=$(echo "$key" | xargs) # trim whitespace
13
+ val=$(echo "$val" | xargs)
14
+ # Only set if not already in environment
15
+ if [ -z "${!key+x}" ]; then
16
+ export "$key=$val"
17
+ fi
18
+ done < "$SCRIPT_DIR/.env"
12
19
  fi
13
20
 
14
21
  MCP_CONFIG="${MCP_CONFIG:-$SCRIPT_DIR/mcp-config.json}"
@@ -217,6 +224,37 @@ while true; do
217
224
  echo ""
218
225
  fi
219
226
 
227
+ # Poll for pending spawn task (queued via HUD Shift+Enter or POST /spawn)
228
+ SPAWN=$(curl -sf "$CORE_URL/spawn/pending" 2>/dev/null || echo '{"ok":false}')
229
+ 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)
230
+
231
+ if [ -n "$SPAWN_ID" ]; then
232
+ SPAWN_TASK=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['task']['task'])" 2>/dev/null)
233
+ SPAWN_LABEL=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['task'].get('label','task'))" 2>/dev/null)
234
+
235
+ echo "[$(date +%H:%M:%S)] Spawn task $SPAWN_ID ($SPAWN_LABEL)"
236
+
237
+ if agent_has_mcp; then
238
+ # MCP path: agent runs task with sinain tools available
239
+ SPAWN_PROMPT="You have a background task to complete. Task: $SPAWN_TASK
240
+
241
+ Complete this task thoroughly. Use sinain_get_knowledge and sinain_knowledge_query if you need context from past sessions. Summarize your findings concisely."
242
+ SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" || echo "ERROR: agent invocation failed")
243
+ else
244
+ # Pipe path: agent gets task text directly
245
+ SPAWN_RESULT=$(invoke_pipe "Background task: $SPAWN_TASK" || echo "No output")
246
+ fi
247
+
248
+ # Post result back
249
+ if [ -n "$SPAWN_RESULT" ]; then
250
+ curl -sf -X POST "$CORE_URL/spawn/respond" \
251
+ -H 'Content-Type: application/json' \
252
+ -d "{\"id\":\"$SPAWN_ID\",\"result\":$(echo "$SPAWN_RESULT" | json_encode)}" >/dev/null 2>&1 || true
253
+ echo "[$(date +%H:%M:%S)] Spawn $SPAWN_ID completed: ${SPAWN_RESULT:0:120}..."
254
+ fi
255
+ echo ""
256
+ fi
257
+
220
258
  # Heartbeat check
221
259
  NOW=$(date +%s)
222
260
  ELAPSED=$((NOW - LAST_HEARTBEAT))
@@ -78,6 +78,9 @@ export class Escalator {
78
78
  private pendingUserCommand: UserCommand | null = null;
79
79
  private static readonly USER_COMMAND_EXPIRY_MS = 120_000; // 2 minutes
80
80
 
81
+ // HTTP spawn queue — for bare agents that poll (mirrors httpPending for escalation)
82
+ private spawnHttpPending: { id: string; task: string; label: string; ts: number } | null = null;
83
+
81
84
  private stats = {
82
85
  totalEscalations: 0,
83
86
  totalResponses: 0,
@@ -397,6 +400,37 @@ ${recentLines.join("\n")}`;
397
400
  return { ok: true };
398
401
  }
399
402
 
403
+ /** Return the current HTTP pending spawn task (or null). */
404
+ getSpawnPending(): { id: string; task: string; label: string; ts: number } | null {
405
+ return this.spawnHttpPending;
406
+ }
407
+
408
+ /** Respond to a pending spawn task from a bare agent. */
409
+ respondSpawn(id: string, result: string): { ok: boolean; error?: string } {
410
+ if (!this.spawnHttpPending) {
411
+ return { ok: false, error: "no pending spawn task" };
412
+ }
413
+ if (this.spawnHttpPending.id !== id) {
414
+ return { ok: false, error: `id mismatch: expected ${this.spawnHttpPending.id}` };
415
+ }
416
+
417
+ const label = this.spawnHttpPending.label;
418
+ const startedAt = this.spawnHttpPending.ts;
419
+
420
+ // Push result to HUD feed
421
+ const maxLen = 3000;
422
+ const text = `[🔧 ${label}] ${result.trim().slice(0, maxLen)}`;
423
+ this.deps.feedBuffer.push(text, "high", "openclaw", "agent");
424
+ this.deps.wsHandler.broadcast(text, "high", "agent");
425
+
426
+ // Broadcast completion
427
+ this.broadcastTaskEvent(id, "completed", label, startedAt, result.slice(0, 200));
428
+
429
+ log(TAG, `spawn ${id} responded (${result.length} chars)`);
430
+ this.spawnHttpPending = null;
431
+ return { ok: true };
432
+ }
433
+
400
434
  /** Whether the gateway WS client is currently connected. */
401
435
  get isGatewayConnected(): boolean {
402
436
  return this.wsClient.isConnected;
@@ -468,8 +502,12 @@ ${recentLines.join("\n")}`;
468
502
  this.broadcastTaskEvent(taskId, "spawned", label, startedAt);
469
503
 
470
504
  if (!this.wsClient.isConnected) {
471
- warn(TAG, `spawn-task ${taskId}: WS disconnected cannot dispatch`);
472
- this.broadcastTaskEvent(taskId, "failed", label, startedAt);
505
+ // No OpenClaw gatewayqueue for bare agent HTTP polling
506
+ this.spawnHttpPending = { id: taskId, task, label: label || "background-task", ts: startedAt };
507
+ const preview = task.length > 60 ? task.slice(0, 60) + "…" : task;
508
+ this.deps.feedBuffer.push(`🔧 Task queued for agent: ${preview}`, "normal", "system", "stream");
509
+ this.deps.wsHandler.broadcast(`🔧 Task queued for agent: ${preview}`, "normal");
510
+ log(TAG, `spawn-task ${taskId}: WS disconnected — queued for bare agent polling`);
473
511
  return;
474
512
  }
475
513
 
@@ -416,6 +416,15 @@ async function main() {
416
416
  return execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" });
417
417
  } catch { return ""; }
418
418
  },
419
+
420
+ // Spawn background agent task (from HUD Shift+Enter or bare agent POST /spawn)
421
+ onSpawnCommand: (text: string) => {
422
+ escalator.dispatchSpawnTask(text, "user-command").catch((err) => {
423
+ log("srv", `spawn via HTTP failed: ${err}`);
424
+ });
425
+ },
426
+ getSpawnPending: () => escalator.getSpawnPending(),
427
+ respondSpawn: (id: string, result: string) => escalator.respondSpawn(id, result),
419
428
  });
420
429
 
421
430
  // ── Wire overlay profiling ──
@@ -435,6 +444,12 @@ async function main() {
435
444
  onUserCommand: (text) => {
436
445
  escalator.setUserCommand(text);
437
446
  },
447
+ onSpawnCommand: (text) => {
448
+ escalator.dispatchSpawnTask(text, "user-command").catch((err) => {
449
+ log("cmd", `spawn command failed: ${err}`);
450
+ wsHandler.broadcast(`\u26a0 Spawn failed: ${String(err).slice(0, 100)}`, "normal");
451
+ });
452
+ },
438
453
  onToggleScreen: () => {
439
454
  screenActive = !screenActive;
440
455
  if (!screenActive) {
@@ -15,6 +15,8 @@ export interface CommandDeps {
15
15
  onUserMessage: (text: string) => Promise<void>;
16
16
  /** Queue a user command to augment the next escalation */
17
17
  onUserCommand: (text: string) => void;
18
+ /** Spawn a background agent task */
19
+ onSpawnCommand?: (text: string) => void;
18
20
  /** Toggle screen capture — returns new state */
19
21
  onToggleScreen: () => boolean;
20
22
  /** Toggle trait voices — returns new enabled state */
@@ -44,6 +46,17 @@ export function setupCommands(deps: CommandDeps): void {
44
46
  deps.onUserCommand(msg.text);
45
47
  break;
46
48
  }
49
+ case "spawn_command": {
50
+ const preview = msg.text.length > 60 ? msg.text.slice(0, 60) + "…" : msg.text;
51
+ log(TAG, `spawn command received: "${preview}"`);
52
+ if (deps.onSpawnCommand) {
53
+ deps.onSpawnCommand(msg.text);
54
+ } else {
55
+ log(TAG, `spawn command ignored — no handler configured`);
56
+ wsHandler.broadcast(`⚠ Spawn not available (no agent gateway connected)`, "normal");
57
+ }
58
+ break;
59
+ }
47
60
  case "command": {
48
61
  handleCommand(msg.action, deps);
49
62
  log(TAG, `command processed: ${msg.action}`);
@@ -39,6 +39,9 @@ export interface ServerDeps {
39
39
  respondEscalation?: (id: string, response: string) => any;
40
40
  getKnowledgeDocPath?: () => string | null;
41
41
  queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
42
+ onSpawnCommand?: (text: string) => void;
43
+ getSpawnPending?: () => { id: string; task: string; label: string; ts: number } | null;
44
+ respondSpawn?: (id: string, result: string) => { ok: boolean; error?: string };
42
45
  }
43
46
 
44
47
  function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
@@ -341,6 +344,45 @@ export function createAppServer(deps: ServerDeps) {
341
344
  return;
342
345
  }
343
346
 
347
+ // ── /spawn ──
348
+ if (req.method === "POST" && url.pathname === "/spawn") {
349
+ const body = await readBody(req, 65536);
350
+ const { text, label } = JSON.parse(body);
351
+ if (!text) {
352
+ res.writeHead(400);
353
+ res.end(JSON.stringify({ ok: false, error: "missing text" }));
354
+ return;
355
+ }
356
+ if (deps.onSpawnCommand) {
357
+ deps.onSpawnCommand(text);
358
+ res.end(JSON.stringify({ ok: true, spawned: true }));
359
+ } else {
360
+ res.end(JSON.stringify({ ok: false, error: "spawn not configured" }));
361
+ }
362
+ return;
363
+ }
364
+
365
+ // ── /spawn/pending (bare agent polls for queued tasks) ──
366
+ if (req.method === "GET" && url.pathname === "/spawn/pending") {
367
+ const task = deps.getSpawnPending?.() ?? null;
368
+ res.end(JSON.stringify({ ok: true, task }));
369
+ return;
370
+ }
371
+
372
+ // ── /spawn/respond (bare agent returns task result) ──
373
+ if (req.method === "POST" && url.pathname === "/spawn/respond") {
374
+ const body = await readBody(req, 65536);
375
+ const { id, result } = JSON.parse(body);
376
+ if (!id || !result) {
377
+ res.writeHead(400);
378
+ res.end(JSON.stringify({ ok: false, error: "missing id or result" }));
379
+ return;
380
+ }
381
+ const resp = deps.respondSpawn?.(id, result) ?? { ok: false, error: "spawn not configured" };
382
+ res.end(JSON.stringify(resp));
383
+ return;
384
+ }
385
+
344
386
  res.writeHead(404);
345
387
  res.end(JSON.stringify({ error: "not found" }));
346
388
  } catch (err: any) {
@@ -72,8 +72,14 @@ export interface UserCommandMessage {
72
72
  text: string;
73
73
  }
74
74
 
75
+ /** Overlay → sinain-core: spawn a background agent task */
76
+ export interface SpawnCommandMessage {
77
+ type: "spawn_command";
78
+ text: string;
79
+ }
80
+
75
81
  export type OutboundMessage = FeedMessage | StatusMessage | PingMessage | SpawnTaskMessage;
76
- export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage;
82
+ export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage | SpawnCommandMessage;
77
83
 
78
84
  /** Abstraction for user commands (text now, voice later). */
79
85
  export interface UserCommand {
@@ -152,6 +152,24 @@ server.tool(
152
152
  );
153
153
 
154
154
  // 6. sinain_post_feed
155
+ // 6b. sinain_spawn
156
+ server.tool(
157
+ "sinain_spawn",
158
+ "Spawn a background agent task via sinain-core",
159
+ {
160
+ task: z.string(),
161
+ label: z.string().optional().default("background-task"),
162
+ },
163
+ async ({ task, label }) => {
164
+ try {
165
+ const data = await coreRequest("POST", "/spawn", { text: task, label });
166
+ return textResult(JSON.stringify(data, null, 2));
167
+ } catch (err: any) {
168
+ return textResult(`Error spawning task: ${err.message}`);
169
+ }
170
+ },
171
+ );
172
+
155
173
  server.tool(
156
174
  "sinain_post_feed",
157
175
  "Post a message to the sinain-core HUD feed",
@@ -1,92 +0,0 @@
1
- # sinain-core configuration
2
- # Copy to .env and fill in your values: cp .env.example .env
3
-
4
- # ── Server ──
5
- PORT=9500
6
-
7
- # ── System Audio ──
8
- # Default: ScreenCaptureKit (zero-setup, macOS 13+). Fallback: ffmpeg + BlackHole.
9
- AUDIO_CAPTURE_CMD=screencapturekit # screencapturekit | sox | ffmpeg
10
- AUDIO_DEVICE=BlackHole 2ch # macOS audio device (only used by sox/ffmpeg)
11
- AUDIO_SAMPLE_RATE=16000
12
- AUDIO_CHUNK_MS=5000
13
- AUDIO_VAD_ENABLED=true
14
- AUDIO_VAD_THRESHOLD=0.003
15
- AUDIO_AUTO_START=true
16
- AUDIO_GAIN_DB=20
17
-
18
- # ── Microphone (opt-in for privacy) ──
19
- MIC_ENABLED=false # set true to capture user's microphone
20
- MIC_DEVICE=default # "default" = system mic. For specific device: use exact CoreAudio name
21
- MIC_CAPTURE_CMD=sox # sox or ffmpeg (mic uses sox by default)
22
- MIC_SAMPLE_RATE=16000
23
- MIC_CHUNK_MS=5000
24
- MIC_VAD_ENABLED=true
25
- MIC_VAD_THRESHOLD=0.008 # higher threshold (ambient noise)
26
- MIC_AUTO_START=false
27
- MIC_GAIN_DB=0
28
-
29
- # ── Transcription ──
30
- OPENROUTER_API_KEY= # required (unless TRANSCRIPTION_BACKEND=local)
31
- TRANSCRIPTION_BACKEND=openrouter # openrouter | local (local = whisper.cpp on-device)
32
- TRANSCRIPTION_MODEL=google/gemini-2.5-flash
33
- TRANSCRIPTION_LANGUAGE=en-US
34
-
35
- # ── Local Transcription (only when TRANSCRIPTION_BACKEND=local) ──
36
- # Install: brew install whisper-cpp
37
- # Models: https://huggingface.co/ggerganov/whisper.cpp/tree/main
38
- # LOCAL_WHISPER_BIN=whisper-cli
39
- # LOCAL_WHISPER_MODEL=~/models/ggml-large-v3-turbo.bin
40
- # LOCAL_WHISPER_TIMEOUT_MS=15000
41
-
42
- # ── Agent ──
43
- AGENT_ENABLED=true
44
- AGENT_MODEL=google/gemini-2.5-flash-lite
45
- # AGENT_FALLBACK_MODELS=google/gemini-2.5-flash,anthropic/claude-3.5-haiku
46
- AGENT_MAX_TOKENS=300
47
- AGENT_TEMPERATURE=0.3
48
- AGENT_PUSH_TO_FEED=true
49
- AGENT_DEBOUNCE_MS=3000
50
- AGENT_MAX_INTERVAL_MS=30000
51
- AGENT_COOLDOWN_MS=10000
52
- AGENT_MAX_AGE_MS=120000 # context window lookback (2 min)
53
-
54
- # ── Escalation ──
55
- ESCALATION_MODE=selective # off | selective | focus | rich
56
- ESCALATION_COOLDOWN_MS=30000
57
- # ESCALATION_TRANSPORT=auto # ws | http | auto — use http for bare agent (no gateway)
58
- # auto = WS when gateway connected, HTTP fallback
59
- # http = skip gateway entirely, poll via GET /escalation/pending
60
- # See docs/INSTALL-BARE-AGENT.md for bare agent setup
61
-
62
- # ── OpenClaw / NemoClaw Gateway ─────────────────────────────────────────────
63
- # Run ./setup-nemoclaw.sh to fill these in interactively (recommended).
64
- #
65
- # NemoClaw (NVIDIA Brev) quick-start:
66
- # 1. In Brev dashboard: Expose Port(s) → enter 18789 → TCP → note the IP
67
- # 2. In Code-Server terminal: npx sinain (installs plugin, prints token)
68
- # 3. On Mac: ./setup-nemoclaw.sh (interactive wizard)
69
- #
70
- # URL: ws://YOUR-IP:18789 (use the IP shown after exposing port 18789)
71
- # Token: printed by `npx sinain` / visible in Brev dashboard → Gateway Token
72
- OPENCLAW_WS_URL=ws://localhost:18789
73
- OPENCLAW_WS_TOKEN= # 48-char hex — from gateway config or `npx sinain` output
74
- OPENCLAW_HTTP_URL=http://localhost:18789/hooks/agent
75
- OPENCLAW_HTTP_TOKEN= # same token as WS_TOKEN
76
- OPENCLAW_SESSION_KEY=agent:main:sinain # MUST be agent:main:sinain — see README § Session Key
77
- # OPENCLAW_PHASE1_TIMEOUT_MS=10000 # Phase 1 (delivery) timeout — circuit trips on failure
78
- # OPENCLAW_PHASE2_TIMEOUT_MS=120000 # Phase 2 (agent response) timeout — no circuit trip
79
- # OPENCLAW_QUEUE_TTL_MS=300000 # Outbound queue message TTL (5 min)
80
- # OPENCLAW_QUEUE_MAX_SIZE=10 # Max queued escalations (oldest dropped on overflow)
81
- # OPENCLAW_PING_INTERVAL_MS=30000 # WS ping keepalive interval
82
-
83
- # ── SITUATION.md ──
84
- SITUATION_MD_PATH=~/.openclaw/workspace/SITUATION.md
85
- # OPENCLAW_WORKSPACE_DIR=~/.openclaw/workspace
86
-
87
- # ── Debug ──
88
- # DEBUG=true # verbose logging (every tick, every chunk)
89
-
90
- # ── Tracing ──
91
- TRACE_ENABLED=true
92
- TRACE_DIR=~/.sinain-core/traces