@geravant/sinain 1.10.1 → 1.12.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 (44) hide show
  1. package/package.json +1 -1
  2. package/sinain-agent/CLAUDE.md +1 -1
  3. package/sinain-agent/run.sh +66 -7
  4. package/sinain-core/src/agent/analyzer.ts +4 -27
  5. package/sinain-core/src/agent/loop.ts +10 -40
  6. package/sinain-core/src/agent/situation-writer.ts +0 -16
  7. package/sinain-core/src/config.ts +1 -9
  8. package/sinain-core/src/escalation/escalator.ts +44 -16
  9. package/sinain-core/src/escalation/message-builder.ts +45 -118
  10. package/sinain-core/src/index.ts +20 -36
  11. package/sinain-core/src/learning/local-curation.ts +4 -4
  12. package/sinain-core/src/overlay/commands.ts +46 -13
  13. package/sinain-core/src/overlay/ws-handler.ts +13 -1
  14. package/sinain-core/src/server.ts +121 -0
  15. package/sinain-core/src/types.ts +25 -28
  16. package/sinain-mcp-server/index.ts +28 -0
  17. package/sinain-memory/__pycache__/graph_query.cpython-312.pyc +0 -0
  18. package/sinain-memory/__pycache__/triplestore.cpython-312.pyc +0 -0
  19. package/sinain-memory/eval/__pycache__/__init__.cpython-312.pyc +0 -0
  20. package/sinain-memory/eval/assertions.py +0 -21
  21. package/sinain-memory/eval/benchmarks/__init__.py +0 -0
  22. package/sinain-memory/eval/benchmarks/__pycache__/__init__.cpython-312.pyc +0 -0
  23. package/sinain-memory/eval/benchmarks/__pycache__/base_adapter.cpython-312.pyc +0 -0
  24. package/sinain-memory/eval/benchmarks/__pycache__/config.cpython-312.pyc +0 -0
  25. package/sinain-memory/eval/benchmarks/__pycache__/evaluate.cpython-312.pyc +0 -0
  26. package/sinain-memory/eval/benchmarks/__pycache__/ingest.cpython-312.pyc +0 -0
  27. package/sinain-memory/eval/benchmarks/__pycache__/longmemeval_adapter.cpython-312.pyc +0 -0
  28. package/sinain-memory/eval/benchmarks/__pycache__/query.cpython-312.pyc +0 -0
  29. package/sinain-memory/eval/benchmarks/__pycache__/report.cpython-312.pyc +0 -0
  30. package/sinain-memory/eval/benchmarks/__pycache__/runner.cpython-312.pyc +0 -0
  31. package/sinain-memory/eval/benchmarks/base_adapter.py +43 -0
  32. package/sinain-memory/eval/benchmarks/config.py +23 -0
  33. package/sinain-memory/eval/benchmarks/evaluate.py +146 -0
  34. package/sinain-memory/eval/benchmarks/ingest.py +152 -0
  35. package/sinain-memory/eval/benchmarks/judges/__init__.py +0 -0
  36. package/sinain-memory/eval/benchmarks/judges/__pycache__/__init__.cpython-312.pyc +0 -0
  37. package/sinain-memory/eval/benchmarks/judges/__pycache__/qa_judge.cpython-312.pyc +0 -0
  38. package/sinain-memory/eval/benchmarks/judges/qa_judge.py +81 -0
  39. package/sinain-memory/eval/benchmarks/longmemeval_adapter.py +177 -0
  40. package/sinain-memory/eval/benchmarks/query.py +172 -0
  41. package/sinain-memory/eval/benchmarks/report.py +87 -0
  42. package/sinain-memory/eval/benchmarks/runner.py +276 -0
  43. package/sinain-memory/koog-config.json +11 -0
  44. package/sinain-core/src/agent/traits.ts +0 -520
@@ -1,4 +1,4 @@
1
- import type { ContextWindow, AgentEntry, EscalationMode, FeedbackRecord, UserCommand } from "../types.js";
1
+ import type { ContextWindow, AgentEntry, EscalationMode, FeedbackRecord, UserCommand, ResponseSize } from "../types.js";
2
2
  import { normalizeAppName } from "../agent/context-window.js";
3
3
  import { levelFor, applyLevel } from "../privacy/index.js";
4
4
 
@@ -67,11 +67,18 @@ export function isCodingContext(context: ContextWindow): CodingContextResult {
67
67
  };
68
68
  }
69
69
 
70
- function getInstructions(mode: EscalationMode, context: ContextWindow): string {
70
+ function sizeInstruction(size: ResponseSize): string {
71
+ switch (size) {
72
+ case "small": return "1-2 sentences";
73
+ case "large": return "3-5 sentences";
74
+ default: return "2-3 sentences";
75
+ }
76
+ }
77
+
78
+ function getInstructions(context: ContextWindow): string {
71
79
  const { coding, needsSolution } = isCodingContext(context);
72
80
 
73
81
  if (needsSolution) {
74
- // Coding challenge/problem - be very action-oriented
75
82
  return `The user is working on a coding problem. Be PROACTIVE and SOLVE IT:
76
83
 
77
84
  1. Provide a solution approach and working code based on what you can see
@@ -92,13 +99,10 @@ Response should be actionable: working code with brief explanation.`;
92
99
  - If it's a non-code file (config, markdown, email): share a relevant insight, action item, or connection to their current project
93
100
  - If context is minimal: tell a short clever joke (tech humor — never repeat recent ones)
94
101
 
95
- NEVER just describe what the user is doing. Every response must teach, suggest, or connect dots.
96
- (2-5 sentences, or more + code if there's an error or code question).`;
102
+ NEVER just describe what the user is doing. Every response must teach, suggest, or connect dots.`;
97
103
  }
98
104
 
99
- // Non-coding context proactive insights instead of activity descriptions
100
- if (mode === "focus" || mode === "rich") {
101
- return `Based on the above, ALWAYS provide a useful response for the user's HUD.
105
+ return `Based on the above, ALWAYS provide a useful response for the user's HUD.
102
106
  Important: Do NOT respond with NO_REPLY — a response is always required.
103
107
 
104
108
  - If there's an error: investigate and suggest a fix
@@ -109,40 +113,25 @@ Important: Do NOT respond with NO_REPLY — a response is always required.
109
113
 
110
114
  NEVER just describe what the user is doing — they can see their own screen.
111
115
  NEVER respond with "standing by", "monitoring", or similar filler.
112
- Every response must teach something, suggest something, or connect dots the user hasn't noticed.
113
- (2-5 sentences). Be specific and actionable.`;
114
- }
115
-
116
- return `Based on the above, proactively help the user:
117
- - If there's an error: investigate and suggest a fix
118
- - If they seem stuck: offer guidance
119
- - If they're coding: provide relevant insights
120
- - Keep your response concise and actionable (2-5 sentences)`;
116
+ Every response must teach something, suggest something, or connect dots the user hasn't noticed.`;
121
117
  }
122
118
 
123
119
  /**
124
- * Build a structured escalation message with richness proportional to the context window preset.
125
- *
126
- * Expected message sizes:
127
- * lean (selective): ~7 KB / ~1,700 tokens
128
- * standard (focus): ~25 KB / ~6,000 tokens
129
- * rich: ~111 KB / ~28,000 tokens
120
+ * Build a structured escalation message with full context (rich mode).
130
121
  *
131
- * All fit within the 256 KB HTTP hooks limit and 200K+ model context.
132
- *
133
- * In selective mode, sections are prioritized by relevance:
134
- * - Error escalations prioritize error sections
135
- * - Question escalations prioritize audio sections
136
- * - App context is always included
122
+ * Always includes all sections (screen, audio, errors).
123
+ * Response length is controlled by the `responseSize` parameter (small/medium/large)
124
+ * which is set by the user via the HUD overlay slider.
137
125
  */
138
126
  export function buildEscalationMessage(
139
127
  digest: string,
140
128
  context: ContextWindow,
141
129
  entry: AgentEntry,
142
- mode: EscalationMode,
130
+ _mode: EscalationMode,
143
131
  escalationReason?: string,
144
132
  recentFeedback?: FeedbackRecord[],
145
133
  userCommand?: UserCommand,
134
+ responseSize: ResponseSize = "medium",
146
135
  ): string {
147
136
  const sections: string[] = [];
148
137
 
@@ -167,7 +156,6 @@ export function buildEscalationMessage(
167
156
  // Errors — extracted from OCR, full stack traces in rich mode
168
157
  const errors = context.screen.filter(e => hasErrorPattern(e.ocr));
169
158
  const hasErrors = errors.length > 0;
170
- const hasQuestion = escalationReason?.startsWith("question:");
171
159
 
172
160
  // Privacy levels for agent_gateway destination
173
161
  let ocrLevel: import("../types.js").PrivacyLevel = "full";
@@ -183,99 +171,35 @@ export function buildEscalationMessage(
183
171
  const applyAudio = (text: string) => applyLevel(text.slice(0, context.preset.maxTranscriptChars), audioLevel, "audio");
184
172
  const applyTitle = (title: string | undefined) => title ? applyLevel(title, titlesLevel, "titles") : "";
185
173
 
186
- // In selective mode, prioritize sections based on escalation reason
187
- // In focus/rich modes, include everything
188
- if (mode === "selective") {
189
- // Error-triggered: prioritize errors, then screen
190
- if (hasErrors) {
191
- sections.push("## Errors (high priority)");
192
- for (const e of errors) {
193
- sections.push(`\`\`\`\n${applyOcr(e.ocr)}\n\`\`\``);
194
- }
195
- // Include screen context (reduced)
196
- if (context.screen.length > 0) {
197
- sections.push("## Screen (recent OCR)");
198
- for (const e of context.screen.slice(0, 5)) { // Limit in selective mode
199
- const ago = Math.round((Date.now() - e.ts) / 1000);
200
- const app = normalizeAppName(e.meta.app);
201
- const title = applyTitle(e.meta.windowTitle);
202
- const titlePart = title ? ` [${title}]` : "";
203
- sections.push(`- [${ago}s ago] [${app}]${titlePart} ${applyOcr(e.ocr)}`);
204
- }
205
- }
206
- }
207
- // Question-triggered: prioritize audio, then screen
208
- else if (hasQuestion) {
209
- if (context.audio.length > 0) {
210
- sections.push("## Audio (recent transcripts)");
211
- for (const e of context.audio) {
212
- const ago = Math.round((Date.now() - e.ts) / 1000);
213
- sections.push(`- [${ago}s ago] "${applyAudio(e.text)}"`);
214
- }
215
- }
216
- // Include screen context (reduced)
217
- if (context.screen.length > 0) {
218
- sections.push("## Screen (recent OCR)");
219
- for (const e of context.screen.slice(0, 5)) {
220
- const ago = Math.round((Date.now() - e.ts) / 1000);
221
- const app = normalizeAppName(e.meta.app);
222
- const title = applyTitle(e.meta.windowTitle);
223
- const titlePart = title ? ` [${title}]` : "";
224
- sections.push(`- [${ago}s ago] [${app}]${titlePart} ${applyOcr(e.ocr)}`);
225
- }
226
- }
227
- }
228
- // Other triggers: balanced sections
229
- else {
230
- if (context.screen.length > 0) {
231
- sections.push("## Screen (recent OCR)");
232
- for (const e of context.screen) {
233
- const ago = Math.round((Date.now() - e.ts) / 1000);
234
- const app = normalizeAppName(e.meta.app);
235
- const title = applyTitle(e.meta.windowTitle);
236
- const titlePart = title ? ` [${title}]` : "";
237
- sections.push(`- [${ago}s ago] [${app}]${titlePart} ${applyOcr(e.ocr)}`);
238
- }
239
- }
240
- if (context.audio.length > 0) {
241
- sections.push("## Audio (recent transcripts)");
242
- for (const e of context.audio) {
243
- const ago = Math.round((Date.now() - e.ts) / 1000);
244
- sections.push(`- [${ago}s ago] "${applyAudio(e.text)}"`);
245
- }
246
- }
247
- }
248
- } else {
249
- // Focus/rich mode: include all sections
250
- if (hasErrors) {
251
- sections.push("## Errors (high priority)");
252
- for (const e of errors) {
253
- sections.push(`\`\`\`\n${applyOcr(e.ocr)}\n\`\`\``);
254
- }
174
+ // Always include all sections (rich mode)
175
+ if (hasErrors) {
176
+ sections.push("## Errors (high priority)");
177
+ for (const e of errors) {
178
+ sections.push(`\`\`\`\n${applyOcr(e.ocr)}\n\`\`\``);
255
179
  }
180
+ }
256
181
 
257
- if (context.screen.length > 0) {
258
- sections.push("## Screen (recent OCR)");
259
- for (const e of context.screen) {
260
- const ago = Math.round((Date.now() - e.ts) / 1000);
261
- const app = normalizeAppName(e.meta.app);
262
- const title = applyTitle(e.meta.windowTitle);
263
- const titlePart = title ? ` [${title}]` : "";
264
- sections.push(`- [${ago}s ago] [${app}]${titlePart} ${applyOcr(e.ocr)}`);
265
- }
182
+ if (context.screen.length > 0) {
183
+ sections.push("## Screen (recent OCR)");
184
+ for (const e of context.screen) {
185
+ const ago = Math.round((Date.now() - e.ts) / 1000);
186
+ const app = normalizeAppName(e.meta.app);
187
+ const title = applyTitle(e.meta.windowTitle);
188
+ const titlePart = title ? ` [${title}]` : "";
189
+ sections.push(`- [${ago}s ago] [${app}]${titlePart} ${applyOcr(e.ocr)}`);
266
190
  }
191
+ }
267
192
 
268
- if (context.audio.length > 0) {
269
- sections.push("## Audio (recent transcripts)");
270
- for (const e of context.audio) {
271
- const ago = Math.round((Date.now() - e.ts) / 1000);
272
- sections.push(`- [${ago}s ago] "${applyAudio(e.text)}"`);
273
- }
193
+ if (context.audio.length > 0) {
194
+ sections.push("## Audio (recent transcripts)");
195
+ for (const e of context.audio) {
196
+ const ago = Math.round((Date.now() - e.ts) / 1000);
197
+ sections.push(`- [${ago}s ago] "${applyAudio(e.text)}"`);
274
198
  }
275
199
  }
276
200
 
277
- // Mode-specific instructions (now context-aware)
278
- sections.push(getInstructions(mode, context));
201
+ // Context-aware instructions (no size — that's in the response length section below)
202
+ sections.push(getInstructions(context));
279
203
 
280
204
  // Stale escalation hint — forces a proactive response after prolonged silence
281
205
  if (escalationReason === "stale") {
@@ -293,7 +217,10 @@ the local analyzer reported idle/no-change. Provide a PROACTIVE response:
293
217
  sections.push(formatInlineFeedback(recentFeedback));
294
218
  }
295
219
 
296
- sections.push("Respond naturallythis will appear on the user's HUD overlay.");
220
+ // Response length single authoritative size instruction, placed last for salience
221
+ const limit = sizeInstruction(responseSize);
222
+ sections.push(`## Response Length
223
+ Your response MUST be ${limit}. This appears on the user's HUD overlay — be specific and actionable.`);
297
224
 
298
225
  return sections.join("\n\n");
299
226
  }
@@ -8,7 +8,6 @@ import { AudioPipeline } from "./audio/pipeline.js";
8
8
  import type { CaptureSpawner } from "./audio/capture-spawner.js";
9
9
  import { TranscriptionService } from "./audio/transcription.js";
10
10
  import { AgentLoop } from "./agent/loop.js";
11
- import { TraitEngine, loadTraitRoster } from "./agent/traits.js";
12
11
  import { shortAppName } from "./agent/context-window.js";
13
12
  import { Escalator } from "./escalation/escalator.js";
14
13
  import { Recorder } from "./recorder.js";
@@ -344,10 +343,6 @@ async function main() {
344
343
  localCuration.distillPendingSession(); // Recover any session saved before a force-kill
345
344
  localCuration.startPeriodicCuration();
346
345
 
347
- // ── Initialize trait engine ──
348
- const traitRoster = loadTraitRoster(config.traitConfig.configPath);
349
- const traitEngine = new TraitEngine(traitRoster, config.traitConfig);
350
-
351
346
  // ── Initialize escalation ──
352
347
  const escalator = new Escalator({
353
348
  feedBuffer,
@@ -372,33 +367,6 @@ async function main() {
372
367
  // Handle recorder commands
373
368
  const stopResult = recorder.handleCommand(entry.record);
374
369
 
375
- // Dispatch task via subagent spawn
376
- if (entry.task || stopResult) {
377
- let task: string;
378
- let label: string | undefined;
379
-
380
- if (stopResult && stopResult.segments > 0 && entry.task) {
381
- // Recording stopped with explicit task instruction
382
- task = `${entry.task}\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
383
- label = stopResult.title;
384
- } else if (stopResult && stopResult.segments > 0) {
385
- // Recording stopped without explicit task — default to cleanup/summarize
386
- task = `Clean up and summarize this recording transcript:\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
387
- label = stopResult.title;
388
- } else if (entry.task) {
389
- // Standalone task without recording
390
- task = entry.task;
391
- } else {
392
- task = "";
393
- }
394
-
395
- if (task) {
396
- escalator.dispatchSpawnTask(task, label).catch(err => {
397
- error(TAG, "spawn task dispatch error:", err);
398
- });
399
- }
400
- }
401
-
402
370
  // Escalation continues as normal
403
371
  escalator.onAgentAnalysis(entry, contextWindow);
404
372
  },
@@ -422,8 +390,6 @@ async function main() {
422
390
  };
423
391
  return ctx;
424
392
  } : undefined,
425
- traitEngine,
426
- traitLogDir: config.traitConfig.logDir,
427
393
  getKnowledgeDocPath: () => {
428
394
  const workspace = resolveWorkspace();
429
395
  const p = `${workspace}/memory/sinain-knowledge.md`;
@@ -564,6 +530,9 @@ async function main() {
564
530
  // ── Screen capture active flag ──
565
531
  let screenActive = true;
566
532
 
533
+ // ── Escalation pause/resume state ──
534
+ let savedEscalationMode: typeof config.escalationConfig.mode | null = null;
535
+
567
536
  // ── Create HTTP + WS server ──
568
537
  const server = createAppServer({
569
538
  config,
@@ -674,6 +643,7 @@ async function main() {
674
643
 
675
644
  // Bare agent HTTP escalation bridge
676
645
  getEscalationPending: () => escalator.getPendingHttp(),
646
+ isEscalationPaused: () => savedEscalationMode !== null,
677
647
  respondEscalation: (id: string, response: string) => escalator.respondHttp(id, response),
678
648
 
679
649
  // Knowledge graph integration (checks both local and workspace DBs)
@@ -733,7 +703,22 @@ async function main() {
733
703
  wsHandler.updateState({ screen: screenActive ? "active" : "off" });
734
704
  return screenActive;
735
705
  },
736
- onToggleTraits: () => traitEngine.toggle(),
706
+ onToggleEscalation: () => {
707
+ if (savedEscalationMode === null) {
708
+ // Pause: save current mode, switch to off
709
+ savedEscalationMode = config.escalationConfig.mode;
710
+ escalator.setMode("off");
711
+ log(TAG, `escalation paused (was: ${savedEscalationMode})`);
712
+ return false;
713
+ } else {
714
+ // Resume: restore saved mode
715
+ const mode = savedEscalationMode;
716
+ savedEscalationMode = null;
717
+ escalator.setMode(mode);
718
+ log(TAG, `escalation resumed (mode: ${mode})`);
719
+ return true;
720
+ }
721
+ },
737
722
  });
738
723
 
739
724
  // Broadcast initial screen state so overlay gets correct status on connect
@@ -795,7 +780,6 @@ async function main() {
795
780
  log(TAG, ` mic: ${config.micEnabled ? (config.micConfig.autoStart ? "active" : "standby") : "disabled"}`);
796
781
  log(TAG, ` agent: ${config.agentConfig.enabled ? "enabled" : "disabled"}`);
797
782
  log(TAG, ` escal: ${config.escalationConfig.mode}`);
798
- log(TAG, ` traits: ${config.traitConfig.enabled ? "enabled" : "disabled"} (${traitRoster.length} traits)`);
799
783
  log(TAG, ` cost: display=${config.costDisplayEnabled ? "on" : "off"} (always logged)`);
800
784
 
801
785
  // ── Graceful shutdown ──
@@ -95,7 +95,7 @@ export class LocalCurationService {
95
95
  * Called during shutdown — instant (no LLM), survives tsx force-kill.
96
96
  */
97
97
  savePendingSession(feedItems: FeedItem[]): void {
98
- if (feedItems.length < 3) {
98
+ if (feedItems.length < 1) {
99
99
  log(TAG, `skipping save — only ${feedItems.length} feed items`);
100
100
  return;
101
101
  }
@@ -135,7 +135,7 @@ export class LocalCurationService {
135
135
  }
136
136
 
137
137
  const items: FeedItem[] = data.items || [];
138
- if (items.length < 3) {
138
+ if (items.length < 1) {
139
139
  log(TAG, `pending session too small (${items.length} items) — removing`);
140
140
  unlinkSync(pendingPath);
141
141
  return;
@@ -160,7 +160,7 @@ export class LocalCurationService {
160
160
  * picked up on next startup via distillPendingSession().
161
161
  */
162
162
  async distillSession(feedItems: FeedItem[]): Promise<void> {
163
- if (feedItems.length < 3) {
163
+ if (feedItems.length < 1) {
164
164
  log(TAG, `skipping distillation — only ${feedItems.length} feed items`);
165
165
  return;
166
166
  }
@@ -332,7 +332,7 @@ export class LocalCurationService {
332
332
 
333
333
  /** Fallback: write raw feed summary when distillation fails. */
334
334
  private writeDailyNotesFallback(feedItems: FeedItem[]): void {
335
- if (feedItems.length < 3) return;
335
+ if (feedItems.length < 1) return;
336
336
 
337
337
  const date = new Date().toISOString().slice(0, 10);
338
338
  const notesPath = resolve(this.memoryDir, `${date}.md`);
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import type { InboundMessage } from "../types.js";
2
+ import type { InboundMessage, ResponseSize } from "../types.js";
3
3
  import type { WsHandler } from "./ws-handler.js";
4
4
  import type { AudioPipeline } from "../audio/pipeline.js";
5
5
  import type { CoreConfig } from "../types.js";
@@ -21,8 +21,8 @@ export interface CommandDeps {
21
21
  onSpawnCommand?: (text: string) => void;
22
22
  /** Toggle screen capture — returns new state */
23
23
  onToggleScreen: () => boolean;
24
- /** Toggle trait voices — returns new enabled state */
25
- onToggleTraits?: () => boolean;
24
+ /** Toggle escalation pause/resume — returns true if now active */
25
+ onToggleEscalation: () => boolean;
26
26
  }
27
27
 
28
28
  /**
@@ -79,8 +79,29 @@ export function setupCommands(deps: CommandDeps): void {
79
79
  }
80
80
  break;
81
81
  }
82
+ case "spawn_reply": {
83
+ const { taskId, text } = msg as any;
84
+ log(TAG, `spawn reply for ${taskId}: "${(text || "").slice(0, 60)}"`);
85
+ // Forward to the /spawn/reply HTTP endpoint internally
86
+ fetch(`http://localhost:${deps.config.port}/spawn/reply`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ taskId, text }),
90
+ }).catch(() => {});
91
+ break;
92
+ }
93
+ case "spawn_permission_reply": {
94
+ const { taskId, decision } = msg as any;
95
+ log(TAG, `spawn permission reply for ${taskId}: ${decision}`);
96
+ fetch(`http://localhost:${deps.config.port}/spawn/permission-reply`, {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify({ taskId, decision }),
100
+ }).catch(() => {});
101
+ break;
102
+ }
82
103
  case "command": {
83
- handleCommand(msg.action, deps);
104
+ handleCommand(msg, deps);
84
105
  log(TAG, `command processed: ${msg.action}`);
85
106
  break;
86
107
  }
@@ -88,8 +109,11 @@ export function setupCommands(deps: CommandDeps): void {
88
109
  });
89
110
  }
90
111
 
91
- function handleCommand(action: string, deps: CommandDeps): void {
112
+ const VALID_RESPONSE_SIZES = new Set<ResponseSize>(["small", "medium", "large"]);
113
+
114
+ function handleCommand(msg: InboundMessage & { action: string }, deps: CommandDeps): void {
92
115
  const { wsHandler, systemAudioPipeline, micPipeline } = deps;
116
+ const action = msg.action;
93
117
 
94
118
  switch (action) {
95
119
  case "toggle_audio": {
@@ -142,15 +166,24 @@ function handleCommand(action: string, deps: CommandDeps): void {
142
166
  log(TAG, `screen toggled ${nowActive ? "ON" : "OFF"}`);
143
167
  break;
144
168
  }
145
- case "toggle_traits": {
146
- if (!deps.onToggleTraits) {
147
- wsHandler.broadcast("Trait voices not configured", "normal");
148
- break;
169
+ case "toggle_escalation": {
170
+ const nowActive = deps.onToggleEscalation();
171
+ wsHandler.updateState({ escalation: nowActive ? "active" : "paused" });
172
+ wsHandler.broadcast(
173
+ nowActive ? "Escalations resumed" : "Escalations paused — context still accumulating",
174
+ "normal"
175
+ );
176
+ log(TAG, `escalation toggled ${nowActive ? "ON" : "OFF"}`);
177
+ break;
178
+ }
179
+ case "set_response_size": {
180
+ const size = (msg as any).responseSize as string;
181
+ if (VALID_RESPONSE_SIZES.has(size as ResponseSize)) {
182
+ wsHandler.updateState({ responseSize: size as ResponseSize });
183
+ log(TAG, `response size set to ${size}`);
184
+ } else {
185
+ log(TAG, `invalid response size: ${size}`);
149
186
  }
150
- const nowEnabled = deps.onToggleTraits();
151
- wsHandler.updateState({ traits: nowEnabled ? "active" : "off" });
152
- wsHandler.broadcast(`Trait voices ${nowEnabled ? "on" : "off"}`, "normal");
153
- log(TAG, `traits toggled ${nowEnabled ? "ON" : "OFF"}`);
154
187
  break;
155
188
  }
156
189
  case "open_settings": {
@@ -37,7 +37,9 @@ export class WsHandler {
37
37
  audio: "muted",
38
38
  mic: "muted",
39
39
  screen: "off",
40
+ escalation: "active",
40
41
  connection: "disconnected",
42
+ responseSize: "medium",
41
43
  };
42
44
  private replayBuffer: FeedMessage[] = [];
43
45
  private spawnTaskBuffer: Map<string, SpawnTaskMessage> = new Map();
@@ -72,7 +74,9 @@ export class WsHandler {
72
74
  audio: this.state.audio,
73
75
  mic: this.state.mic,
74
76
  screen: this.state.screen,
77
+ escalation: this.state.escalation,
75
78
  connection: this.state.connection,
79
+ responseSize: this.state.responseSize,
76
80
  });
77
81
 
78
82
  // Replay recent feed messages for late-joining clients
@@ -149,12 +153,14 @@ export class WsHandler {
149
153
 
150
154
  /** Send a status update to all connected overlays. */
151
155
  broadcastStatus(): void {
152
- const msg: StatusMessage & { envPath?: string } = {
156
+ const msg: StatusMessage & { envPath?: string; escalation?: string; responseSize?: string } = {
153
157
  type: "status",
154
158
  audio: this.state.audio,
155
159
  mic: this.state.mic,
156
160
  screen: this.state.screen,
161
+ escalation: this.state.escalation,
157
162
  connection: this.state.connection,
163
+ responseSize: this.state.responseSize,
158
164
  };
159
165
  if (loadedEnvPath) msg.envPath = loadedEnvPath;
160
166
  this.broadcastMessage(msg);
@@ -229,6 +235,12 @@ export class WsHandler {
229
235
  case "spawn_command":
230
236
  log(TAG, `\u2190 spawn command: ${msg.text.slice(0, 100)}`);
231
237
  break;
238
+ case "spawn_reply":
239
+ log(TAG, `\u2190 spawn reply: taskId=${(msg as any).taskId}`);
240
+ break;
241
+ case "spawn_permission_reply":
242
+ log(TAG, `\u2190 spawn permission reply: taskId=${(msg as any).taskId} decision=${(msg as any).decision}`);
243
+ break;
232
244
  case "profiling":
233
245
  if (this.onProfilingCb) this.onProfilingCb(msg);
234
246
  return;
@@ -174,6 +174,7 @@ export interface ServerDeps {
174
174
  feedbackStore?: FeedbackStore;
175
175
  setUserCommand?: (text: string) => void;
176
176
  getEscalationPending?: () => any;
177
+ isEscalationPaused?: () => boolean;
177
178
  respondEscalation?: (id: string, response: string) => any;
178
179
  getKnowledgeDocPath?: () => string | null;
179
180
  queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
@@ -203,6 +204,9 @@ function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
203
204
  });
204
205
  }
205
206
 
207
+ /** Pending spawn questions/permissions — resolve callbacks keyed by "ask:{taskId}" or "perm:{taskId}" */
208
+ const pendingSpawnQuestions = new Map<string, (answer: string) => void>();
209
+
206
210
  export function createAppServer(deps: ServerDeps) {
207
211
  const { config, feedBuffer, senseBuffer, wsHandler } = deps;
208
212
  let senseInBytes = 0;
@@ -562,6 +566,11 @@ export function createAppServer(deps: ServerDeps) {
562
566
 
563
567
  // ── /escalation/pending ──
564
568
  if (req.method === "GET" && url.pathname === "/escalation/pending") {
569
+ const paused = deps.isEscalationPaused?.() ?? false;
570
+ if (paused) {
571
+ res.end(JSON.stringify({ ok: true, escalation: null, paused: true }));
572
+ return;
573
+ }
565
574
  const pending = deps.getEscalationPending?.();
566
575
  res.end(JSON.stringify({ ok: true, escalation: pending ?? null }));
567
576
  return;
@@ -620,6 +629,118 @@ export function createAppServer(deps: ServerDeps) {
620
629
  return;
621
630
  }
622
631
 
632
+ // ── /spawn/ask (MCP tool posts question, blocks until user replies) ──
633
+ if (req.method === "POST" && url.pathname === "/spawn/ask") {
634
+ const body = await readBody(req, 8192);
635
+ const { taskId, question } = JSON.parse(body);
636
+ if (!taskId || !question) {
637
+ res.writeHead(400);
638
+ res.end(JSON.stringify({ ok: false, error: "missing taskId or question" }));
639
+ return;
640
+ }
641
+ // Broadcast question to overlay
642
+ deps.wsHandler?.broadcastRaw({
643
+ type: "spawn_task",
644
+ taskId,
645
+ label: "user-command",
646
+ status: "awaiting_input",
647
+ startedAt: Date.now(),
648
+ question,
649
+ });
650
+ // Hold response open until user replies (or timeout after 5 min)
651
+ const answer = await new Promise<string>((resolve) => {
652
+ const key = `ask:${taskId}`;
653
+ pendingSpawnQuestions.set(key, resolve);
654
+ setTimeout(() => {
655
+ if (pendingSpawnQuestions.has(key)) {
656
+ pendingSpawnQuestions.delete(key);
657
+ resolve("(no reply — user did not respond within 5 minutes)");
658
+ }
659
+ }, 5 * 60_000);
660
+ });
661
+ res.end(JSON.stringify({ ok: true, answer }));
662
+ return;
663
+ }
664
+
665
+ // ── /spawn/reply (overlay sends answer to a spawn question) ──
666
+ if (req.method === "POST" && url.pathname === "/spawn/reply") {
667
+ const body = await readBody(req, 8192);
668
+ const { taskId, text } = JSON.parse(body);
669
+ const key = `ask:${taskId}`;
670
+ const resolve = pendingSpawnQuestions.get(key);
671
+ if (resolve) {
672
+ pendingSpawnQuestions.delete(key);
673
+ resolve(text || "(empty reply)");
674
+ res.end(JSON.stringify({ ok: true }));
675
+ } else {
676
+ res.end(JSON.stringify({ ok: false, error: "no pending question for this task" }));
677
+ }
678
+ return;
679
+ }
680
+
681
+ // ── /spawn/approve (Claude hook posts tool permission, blocks until user decides) ──
682
+ if (req.method === "POST" && url.pathname === "/spawn/approve") {
683
+ const body = await readBody(req, 16384);
684
+ const hookInput = JSON.parse(body);
685
+ const tool = hookInput?.tool_name || hookInput?.toolName || "unknown";
686
+ const input = hookInput?.tool_input || hookInput?.input || {};
687
+
688
+ // Auto-approve safe read-only tools
689
+ const safeTools = ["Read", "Glob", "Grep", "Ls", "Cat"];
690
+ if (safeTools.includes(tool) || tool.startsWith("mcp__sinain")) {
691
+ res.end(JSON.stringify({
692
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
693
+ }));
694
+ return;
695
+ }
696
+
697
+ const taskId = `perm-${Date.now()}`;
698
+ // Broadcast permission request to overlay
699
+ deps.wsHandler?.broadcastRaw({
700
+ type: "spawn_task",
701
+ taskId,
702
+ label: "permission",
703
+ status: "awaiting_permission",
704
+ startedAt: Date.now(),
705
+ permission: { tool, input },
706
+ });
707
+ // Hold response open until user decides
708
+ const decision = await new Promise<string>((resolve) => {
709
+ const key = `perm:${taskId}`;
710
+ pendingSpawnQuestions.set(key, resolve);
711
+ setTimeout(() => {
712
+ if (pendingSpawnQuestions.has(key)) {
713
+ pendingSpawnQuestions.delete(key);
714
+ resolve("deny"); // default deny on timeout
715
+ }
716
+ }, 2 * 60_000);
717
+ });
718
+ res.end(JSON.stringify({
719
+ hookSpecificOutput: {
720
+ hookEventName: "PreToolUse",
721
+ permissionDecision: decision === "allow" ? "allow" : "deny",
722
+ permissionDecisionReason: decision === "allow" ? "User approved via HUD" : "User denied or timed out",
723
+ },
724
+ }));
725
+ return;
726
+ }
727
+
728
+ // ── /spawn/permission-reply (overlay sends allow/deny) ──
729
+ if (req.method === "POST" && url.pathname === "/spawn/permission-reply") {
730
+ const body = await readBody(req, 1024);
731
+ const { taskId, decision } = JSON.parse(body);
732
+ const key = `perm:${taskId}`;
733
+ const resolve = pendingSpawnQuestions.get(key);
734
+ if (resolve) {
735
+ pendingSpawnQuestions.delete(key);
736
+ resolve(decision || "deny");
737
+ res.end(JSON.stringify({ ok: true }));
738
+ } else {
739
+ res.end(JSON.stringify({ ok: false, error: "no pending permission for this task" }));
740
+ }
741
+ return;
742
+ }
743
+
623
744
  res.writeHead(404);
624
745
  res.end(JSON.stringify({ error: "not found" }));
625
746
  } catch (err: any) {