shraga 0.0.2 → 0.1.1

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 (180) hide show
  1. package/README.md +82 -23
  2. package/defaults/agents/summarizer.md +16 -0
  3. package/defaults/agents/trace-extractor.md +84 -0
  4. package/defaults/bin/claude +45 -0
  5. package/defaults/bin/claude-revive +17 -0
  6. package/defaults/extensions/README.md +70 -0
  7. package/defaults/extensions/selftest.ext.ts +43 -0
  8. package/defaults/extensions/stripe-webhook.ext.ts +58 -0
  9. package/defaults/gmail-triage-prompt.md +42 -0
  10. package/defaults/scripts/README +4 -0
  11. package/defaults/scripts/agent-once.ts +67 -0
  12. package/defaults/scripts/backfill-slack-usernames.ts +82 -0
  13. package/defaults/scripts/notifier-throttle.ts +44 -0
  14. package/defaults/scripts/summarize-conversations.ts +5 -0
  15. package/defaults/shraga.config.ts +29 -0
  16. package/defaults/skills/add-skill.md +14 -0
  17. package/defaults/skills/artifacts.md +116 -0
  18. package/defaults/skills/code-review.md +26 -0
  19. package/defaults/skills/communications.md +54 -0
  20. package/defaults/skills/context-audit.md +87 -0
  21. package/defaults/skills/debug.md +10 -0
  22. package/defaults/skills/garden.md +179 -0
  23. package/defaults/skills/github-contributor.md +35 -0
  24. package/defaults/skills/identity.md +30 -0
  25. package/defaults/skills/mcp-server.md +62 -0
  26. package/defaults/skills/mcps-sync.md +105 -0
  27. package/defaults/skills/plan.md +9 -0
  28. package/defaults/skills/platform.md +177 -0
  29. package/defaults/skills/reconcile.md +239 -0
  30. package/defaults/skills/scheduler.md +192 -0
  31. package/defaults/skills/self-aware.md +136 -0
  32. package/defaults/skills/shraga-know.md +333 -0
  33. package/defaults/skills/stripe.md +55 -0
  34. package/defaults/skills/write-tests.md +10 -0
  35. package/defaults/skills-defaults.json +1 -0
  36. package/defaults/system-prompt.md +46 -0
  37. package/defaults/workspace/context.md +28 -0
  38. package/defaults/workspace.md +50 -0
  39. package/defaults/zdotdir/.gitignore +8 -0
  40. package/defaults/zdotdir/.zlogin +3 -0
  41. package/defaults/zdotdir/.zprofile +1 -0
  42. package/defaults/zdotdir/.zshenv +4 -0
  43. package/defaults/zdotdir/.zshrc +3 -0
  44. package/dist/client/assets/index-BoHttkMt.js +1940 -0
  45. package/dist/client/assets/index-DdibEb2O.css +10 -0
  46. package/dist/client/index.html +22 -0
  47. package/package.json +60 -15
  48. package/src/cli.ts +71 -46
  49. package/src/client/App.tsx +510 -0
  50. package/src/client/components/ArtifactCard.tsx +26 -0
  51. package/src/client/components/ArtifactPanel.tsx +138 -0
  52. package/src/client/components/AuthedImage.tsx +85 -0
  53. package/src/client/components/AutocompleteTextarea.tsx +149 -0
  54. package/src/client/components/ChatView.tsx +866 -0
  55. package/src/client/components/CliAuthConsent.tsx +98 -0
  56. package/src/client/components/ConfigPanel.tsx +328 -0
  57. package/src/client/components/ConversationHeader.tsx +156 -0
  58. package/src/client/components/ConversationPane.tsx +277 -0
  59. package/src/client/components/LoginPage.tsx +81 -0
  60. package/src/client/components/MachineStats.tsx +77 -0
  61. package/src/client/components/McpManager.tsx +209 -0
  62. package/src/client/components/MessageInput.tsx +263 -0
  63. package/src/client/components/OAuthConsent.tsx +103 -0
  64. package/src/client/components/SchedulesManager.tsx +99 -0
  65. package/src/client/components/Sidebar.tsx +235 -0
  66. package/src/client/components/SkillsManager.tsx +280 -0
  67. package/src/client/components/SmartChart.tsx +167 -0
  68. package/src/client/components/Toast.tsx +54 -0
  69. package/src/client/components/WorkspaceTree.tsx +313 -0
  70. package/src/client/components/ZoomableImage.tsx +123 -0
  71. package/src/client/components/artifact-presets.ts +10 -0
  72. package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
  73. package/src/client/components/schedules/ScheduleList.tsx +271 -0
  74. package/src/client/components/ui/accordion.tsx +50 -0
  75. package/src/client/components/ui/button.tsx +43 -0
  76. package/src/client/components/ui/dialog.tsx +82 -0
  77. package/src/client/components/ui/input.tsx +19 -0
  78. package/src/client/components/ui/scroll-area.tsx +39 -0
  79. package/src/client/components/ui/textarea.tsx +18 -0
  80. package/src/client/globals.css +51 -0
  81. package/src/client/hooks/useAgentSocket.ts +79 -0
  82. package/src/client/hooks/useArtifacts.ts +89 -0
  83. package/src/client/hooks/useAuth.ts +127 -0
  84. package/src/client/hooks/useConversation.ts +412 -0
  85. package/src/client/hooks/useDarkMode.ts +57 -0
  86. package/src/client/hooks/useIsMobile.ts +23 -0
  87. package/src/client/hooks/usePush.ts +127 -0
  88. package/src/client/hooks/useSchedules.ts +73 -0
  89. package/src/client/hooks/useUnread.ts +238 -0
  90. package/src/client/lib/desktopAttention.ts +75 -0
  91. package/src/client/lib/firebase.ts +32 -0
  92. package/src/client/lib/googleAuthNative.ts +94 -0
  93. package/src/client/lib/native.ts +43 -0
  94. package/src/client/lib/schedule-types.ts +34 -0
  95. package/src/client/lib/sessionApi.ts +58 -0
  96. package/src/client/lib/slots.tsx +79 -0
  97. package/src/client/lib/storage.ts +39 -0
  98. package/src/client/lib/utils.ts +26 -0
  99. package/src/client/lib/workspaceContext.tsx +54 -0
  100. package/src/client/lib/ws.ts +203 -0
  101. package/src/client/main.tsx +14 -0
  102. package/src/mcp-stdio-bridge.ts +70 -0
  103. package/src/scripts/summarize-conversations.ts +5 -0
  104. package/src/scripts/typecheck.ts +43 -0
  105. package/src/server/agents.ts +54 -0
  106. package/src/server/api-keys.ts +63 -0
  107. package/src/server/artifacts/artifacts.export.ts +85 -0
  108. package/src/server/artifacts/artifacts.handler.ts +93 -0
  109. package/src/server/artifacts/artifacts.routes.ts +43 -0
  110. package/src/server/artifacts/artifacts.service.ts +100 -0
  111. package/src/server/artifacts/artifacts.types.ts +31 -0
  112. package/src/server/auth.ts +262 -0
  113. package/src/server/claude.ts +394 -0
  114. package/src/server/commands.ts +21 -0
  115. package/src/server/contacts.ts +177 -0
  116. package/src/server/conversation-summarizer.ts +204 -0
  117. package/src/server/data-sync.ts +664 -0
  118. package/src/server/directives.ts +91 -0
  119. package/src/server/engine/claude-code.ts +514 -0
  120. package/src/server/engine/index.ts +41 -0
  121. package/src/server/engine/registry.ts +21 -0
  122. package/src/server/engine/shared.ts +47 -0
  123. package/src/server/engine/types.ts +48 -0
  124. package/src/server/env-resolve.ts +71 -0
  125. package/src/server/env-sanitize.ts +9 -0
  126. package/src/server/events/bus.ts +29 -0
  127. package/src/server/events/dispatcher.ts +48 -0
  128. package/src/server/events/routes.ts +19 -0
  129. package/src/server/events/types.ts +9 -0
  130. package/src/server/extensions.ts +101 -0
  131. package/src/server/features.ts +109 -0
  132. package/src/server/file-inject.ts +45 -0
  133. package/src/server/hooks.ts +142 -0
  134. package/src/server/idempotency.ts +25 -0
  135. package/src/server/index.ts +1715 -0
  136. package/src/server/integrity-audit.ts +132 -0
  137. package/src/server/mcp-catalog.ts +70 -0
  138. package/src/server/mcp-oauth.ts +198 -0
  139. package/src/server/mcp-progress.ts +45 -0
  140. package/src/server/mcp-server.ts +456 -0
  141. package/src/server/mcp-sidecar.ts +87 -0
  142. package/src/server/mcp.ts +291 -0
  143. package/src/server/model-aliases.ts +76 -0
  144. package/src/server/paths.ts +24 -0
  145. package/src/server/polls.ts +175 -0
  146. package/src/server/push/apns.ts +113 -0
  147. package/src/server/push/fcm.ts +108 -0
  148. package/src/server/push/push.ts +66 -0
  149. package/src/server/push/store.ts +84 -0
  150. package/src/server/push/triggers.ts +99 -0
  151. package/src/server/scheduler/builtins.ts +157 -0
  152. package/src/server/scheduler/engine.ts +432 -0
  153. package/src/server/scheduler/index.ts +4 -0
  154. package/src/server/scheduler/runner.ts +334 -0
  155. package/src/server/scheduler/storage.ts +98 -0
  156. package/src/server/scheduler/timing.ts +70 -0
  157. package/src/server/scheduler/types.ts +62 -0
  158. package/src/server/sdk-utils.ts +45 -0
  159. package/src/server/seed.ts +174 -0
  160. package/src/server/session-bus.ts +18 -0
  161. package/src/server/sessions.ts +559 -0
  162. package/src/server/shraga-config.ts +167 -0
  163. package/src/server/skills.ts +372 -0
  164. package/src/server/slack/api.ts +37 -0
  165. package/src/server/slack/bot.ts +391 -0
  166. package/src/server/slack/context-cache.ts +42 -0
  167. package/src/server/slack/feature.ts +59 -0
  168. package/src/server/slack/mention-rewrite.ts +59 -0
  169. package/src/server/slack/oauth.ts +102 -0
  170. package/src/server/slack/questions.ts +112 -0
  171. package/src/server/slack/sessions.ts +139 -0
  172. package/src/server/stats.ts +106 -0
  173. package/src/server/summarize.ts +11 -0
  174. package/src/server/turn-context.ts +61 -0
  175. package/src/server/unclaw-config.ts +19 -0
  176. package/src/server/unread.ts +79 -0
  177. package/src/server/user-context.ts +33 -0
  178. package/src/server/vendor-sync.ts +52 -0
  179. package/src/server/voice-provider.ts +74 -0
  180. package/src/server/workspace.ts +249 -0
@@ -0,0 +1,91 @@
1
+ export interface Directives {
2
+ model?: string;
3
+ turns?: number;
4
+ thinking?: 'adaptive' | 'enabled' | 'disabled';
5
+ effort?: 'low' | 'medium' | 'high' | 'max';
6
+ engine?: string;
7
+ }
8
+
9
+ export interface ParsedPrompt {
10
+ prompt: string;
11
+ directives: Directives;
12
+ }
13
+
14
+ /** Model used when neither directives nor config specify one. Always passed
15
+ * explicitly to the SDK — the CLI's own default silently drifts (it picked
16
+ * Opus 4.7), which burns rate limits and budget. */
17
+ export const DEFAULT_MODEL = 'claude-sonnet-4-6';
18
+
19
+ // Canonical model aliases + label. Vendored, pure, dependency-free (src/server/model-aliases.ts).
20
+ // Re-exported here so the rest of shraga keeps importing model helpers from one place.
21
+ export { MODEL_ALIASES, modelShortLabel } from './model-aliases.ts';
22
+ import { MODEL_ALIASES } from './model-aliases.ts';
23
+
24
+ const DIRECTIVE_RE = /^\s*\[([^\]]*)\]\s*([\s\S]*)/;
25
+
26
+ export function parseDirectives(text: string): ParsedPrompt {
27
+ const match = text.match(DIRECTIVE_RE);
28
+ if (!match) return { prompt: text, directives: {} };
29
+
30
+ const raw = match[1].trim();
31
+ const prompt = match[2].trim();
32
+ if (!raw) return { prompt, directives: {} };
33
+
34
+ const directives: Directives = {};
35
+ let positionalIndex = 0;
36
+
37
+ for (const token of raw.split(',')) {
38
+ const t = token.trim();
39
+ if (!t) continue;
40
+
41
+ const colonIdx = t.indexOf(':');
42
+ if (colonIdx !== -1) {
43
+ const key = t.slice(0, colonIdx).trim().toLowerCase();
44
+ const val = t.slice(colonIdx + 1).trim().toLowerCase();
45
+ applyDirective(directives, key, val);
46
+ } else {
47
+ const val = t.toLowerCase();
48
+ if (positionalIndex === 0 && MODEL_ALIASES[val]) {
49
+ directives.model = MODEL_ALIASES[val];
50
+ } else if (positionalIndex <= 1 && /^\d+$/.test(val)) {
51
+ directives.turns = parseInt(val, 10);
52
+ } else if (['think', 'adaptive'].includes(val)) {
53
+ directives.thinking = 'adaptive';
54
+ } else if (['nothink', 'nothinking'].includes(val)) {
55
+ directives.thinking = 'disabled';
56
+ } else if (positionalIndex === 0) {
57
+ console.warn(`[directives] Unknown model alias: "${t}"`);
58
+ }
59
+ positionalIndex++;
60
+ }
61
+ }
62
+
63
+ return { prompt, directives };
64
+ }
65
+
66
+ function applyDirective(d: Directives, key: string, val: string) {
67
+ switch (key) {
68
+ case 'model':
69
+ if (MODEL_ALIASES[val]) d.model = MODEL_ALIASES[val];
70
+ else console.warn(`[directives] Unknown model alias: "${val}"`);
71
+ break;
72
+ case 'turns':
73
+ if (/^\d+$/.test(val)) d.turns = parseInt(val, 10);
74
+ else console.warn(`[directives] Invalid turns value: "${val}"`);
75
+ break;
76
+ case 'thinking':
77
+ case 'think':
78
+ if (['adaptive', 'enabled', 'disabled'].includes(val)) d.thinking = val as Directives['thinking'];
79
+ else console.warn(`[directives] Invalid thinking value: "${val}"`);
80
+ break;
81
+ case 'effort':
82
+ if (['low', 'medium', 'high', 'max'].includes(val)) d.effort = val as Directives['effort'];
83
+ else console.warn(`[directives] Invalid effort value: "${val}"`);
84
+ break;
85
+ case 'engine':
86
+ d.engine = val;
87
+ break;
88
+ default:
89
+ console.warn(`[directives] Unknown directive key: "${key}"`);
90
+ }
91
+ }
@@ -0,0 +1,514 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { signInternalToken } from '../auth.ts';
5
+ import { buildHooks } from '../hooks.ts';
6
+ import { listSkills } from '../skills.ts';
7
+ import { loadAgents } from '../agents.ts';
8
+ import { registerProactiveMessage } from '../slack/sessions.ts';
9
+ import { registerPoll } from '../polls.ts';
10
+ import { getSession, setSessionModel, getSessionModel, type ConvMessage } from '../sessions.ts';
11
+ import { DEFAULT_MODEL } from '../directives.ts';
12
+ import { resolveModelSwitch } from '../model-aliases.ts';
13
+ import type { WsEvent, AskQuestion, QuestionAnswers, QuestionHandler } from '../claude.ts';
14
+ import type { AgentEngine, EngineStreamOpts, EngineModel } from './types.ts';
15
+
16
+ const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
17
+ const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
18
+ const DEFAULT_USER_PROMPT = `You are a helpful assistant with access to MCP tools.`;
19
+ const DEFAULT_ALLOWED_TOOLS = ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'];
20
+ const BG_TASK_MAX_WAIT_MS = 15 * 60_000;
21
+ const HISTORY_LIMIT = 50;
22
+
23
+ const NO_INTERACTIVE_ANSWER = 'No interactive channel is available to answer right now. Use your best judgement to proceed, and surface these options to the user in your reply so they can redirect if needed.';
24
+
25
+ const SENSITIVE_PATTERNS = [
26
+ /\.env($|\.)/i, /secrets?\//i, /credentials/i, /\.pem$/i, /\.key$/i,
27
+ /service.account.*\.json/i, /\/\.claude\/credentials/i,
28
+ ];
29
+ const SENSITIVE_BASH_PATTERNS = [
30
+ /\.env\b/i, /\bprintenv\b/i, /\b(env|set)\s*\|/i, /\bsecrets?\//i,
31
+ /credentials/i, /service.account/i, /\.(pem|key)\b/i,
32
+ /\$[A-Z_]*(KEY|SECRET|TOKEN|PASSWORD)\b/i, /process\.env/i,
33
+ ];
34
+ const DESTRUCTIVE_DATA_PATTERNS = [
35
+ /\b(rm|rmdir|unlink|mv)\b.*\bdata\/(conversations?|sessions?|schedules?)\b/i,
36
+ /\b(rm|rmdir|unlink|mv)\b.*\b(whitelist\.json|api-keys\.json|agent-config\.json)\b/i,
37
+ /\bfind\b.*\bdata\/(conversations?|sessions?|schedules?).*(-delete|-exec\s+rm)\b/i,
38
+ />\s*data\/(conversations?|sessions?|schedules?)\//i,
39
+ ];
40
+
41
+ type DenyResult = { behavior: 'deny'; message: string };
42
+
43
+ function checkSensitiveAccess(toolName: string, input: Record<string, unknown>): DenyResult | null {
44
+ const filePath = (input.file_path ?? input.path ?? '') as string;
45
+ if ((toolName === 'Read' || toolName === 'Edit' || toolName === 'Write') && filePath) {
46
+ if (SENSITIVE_PATTERNS.some(p => p.test(filePath))) {
47
+ console.log(`[security] Blocked ${toolName} on sensitive file: ${filePath}`);
48
+ return { behavior: 'deny', message: 'Access to sensitive files (.env, secrets, credentials) is blocked.' };
49
+ }
50
+ }
51
+ if (toolName === 'Bash') {
52
+ const cmd = (input.command ?? '') as string;
53
+ if (SENSITIVE_BASH_PATTERNS.some(p => p.test(cmd))) {
54
+ console.log(`[security] Blocked Bash command targeting sensitive data: ${cmd.slice(0, 80)}`);
55
+ return { behavior: 'deny', message: 'Commands accessing sensitive files or environment secrets are blocked.' };
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function isDestructiveDataOp(toolName: string, input: Record<string, unknown>): boolean {
62
+ if (toolName !== 'Bash') return false;
63
+ return DESTRUCTIVE_DATA_PATTERNS.some(p => p.test((input.command ?? '') as string));
64
+ }
65
+
66
+ function buildHistoryPrompt(conv: ConvMessage[], contextBlock: string, userPrompt: string): string {
67
+ if (!conv.length) {
68
+ return contextBlock ? `${contextBlock}\n\n${userPrompt}` : userPrompt;
69
+ }
70
+ const summaryIdx = conv.findLastIndex((m) => m.blocks.some((b) => b.type === 'summary'));
71
+ let summary: string | null = null;
72
+ let recent: ConvMessage[];
73
+ if (summaryIdx >= 0) {
74
+ summary = (conv[summaryIdx].blocks.find((b) => b.type === 'summary') as any)?.text ?? null;
75
+ recent = conv.slice(summaryIdx + 1).slice(-HISTORY_LIMIT);
76
+ } else {
77
+ recent = conv.slice(-HISTORY_LIMIT);
78
+ }
79
+
80
+ const parts: string[] = [];
81
+ if (summary) parts.push(`<conversation_summary>\n${summary}\n</conversation_summary>`);
82
+ for (const m of recent) {
83
+ const role = m.role === 'user' ? 'User' : 'Assistant';
84
+ const texts = m.blocks
85
+ .filter((b) => b.type === 'text' || b.type === 'context')
86
+ .map((b) => {
87
+ if (b.type === 'context') return `[${(b as any).label}]: ${(b as any).text}`;
88
+ return (b as { type: 'text'; text: string }).text;
89
+ })
90
+ .filter(Boolean);
91
+ if (texts.length) parts.push(`${role}: ${texts.join('\n')}`);
92
+ }
93
+
94
+ if (parts.length) {
95
+ const prefix = contextBlock ? `${contextBlock}\n\n` : '';
96
+ return `${prefix}<conversation_history>\n${parts.join('\n\n')}\n</conversation_history>\n\nUser: ${userPrompt}`;
97
+ }
98
+ return contextBlock ? `${contextBlock}\n\n${userPrompt}` : userPrompt;
99
+ }
100
+
101
+ /**
102
+ * Log prompt-cache effectiveness from the SDK result `usage`. The hit rate is
103
+ * cache_read / (cache_read + cache_creation + uncached input) — a low rate over
104
+ * many turns points to a silent prefix invalidator or sessions spread past the
105
+ * 5-min cache TTL. Note: cross-turn history is re-sent uncached (single-shot
106
+ * prompt per query, no SDK resume) — so hit rate tracks tool density per turn.
107
+ */
108
+ function logCacheUsage(usage: any, model: string): void {
109
+ if (!usage) return;
110
+ const read = usage.cache_read_input_tokens ?? 0;
111
+ const created = usage.cache_creation_input_tokens ?? 0;
112
+ const fresh = usage.input_tokens ?? 0;
113
+ const totalIn = read + created + fresh;
114
+ if (totalIn === 0) return;
115
+ const hitRate = ((read / totalIn) * 100).toFixed(1);
116
+ console.log(`[claude] Cache: hit=${hitRate}% read=${read} write=${created} uncached=${fresh} out=${usage.output_tokens ?? 0} model=${model}`);
117
+ }
118
+
119
+ const INLINE_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']);
120
+
121
+ async function* buildAttachmentPrompt(text: string, attachments: { path: string; name: string; mimeType: string }[], sessionId: string): AsyncIterable<any> {
122
+ const content: any[] = [];
123
+ const fileRefs: string[] = [];
124
+ const audioRefs: string[] = [];
125
+ for (const att of attachments) {
126
+ if (INLINE_MIMES.has(att.mimeType)) {
127
+ try {
128
+ const buf = readFileSync(att.path);
129
+ const blockType = att.mimeType === 'application/pdf' ? 'document' : 'image';
130
+ content.push({ type: blockType, source: { type: 'base64', media_type: att.mimeType, data: buf.toString('base64') } });
131
+ } catch (err) {
132
+ console.error(`[claude] Failed to read attachment ${att.path}:`, err);
133
+ fileRefs.push(`${att.name} (at ${att.path} — failed to read)`);
134
+ }
135
+ } else if (att.mimeType.startsWith('audio/')) {
136
+ // The model can't ingest audio directly — pass the path and route to mcp-audio.
137
+ audioRefs.push(`${att.name} (at ${att.path})`);
138
+ } else {
139
+ fileRefs.push(`${att.name} (at ${att.path})`);
140
+ }
141
+ }
142
+ if (audioRefs.length > 0) text += `\n\n[Audio attached — transcribe with the mcp-audio tool (post_audio_transcribe { file }) before answering]: ${audioRefs.join(', ')}`;
143
+ if (fileRefs.length > 0) text += `\n\n[Attached files — use Read tool to access]: ${fileRefs.join(', ')}`;
144
+ content.push({ type: 'text', text });
145
+ yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: sessionId };
146
+ }
147
+
148
+ async function* buildLegacyImagePrompt(text: string, images: string[], sessionId: string): AsyncIterable<any> {
149
+ const content: any[] = images.map((dataUrl) => {
150
+ const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
151
+ if (!match) return null;
152
+ const blockType = match[1] === 'application/pdf' ? 'document' : 'image';
153
+ return { type: blockType, source: { type: 'base64', media_type: match[1], data: match[2] } };
154
+ }).filter(Boolean);
155
+ content.push({ type: 'text', text });
156
+ yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: sessionId };
157
+ }
158
+
159
+ export class ClaudeCodeEngine implements AgentEngine {
160
+ readonly name = 'claude-code';
161
+
162
+ getModels(): EngineModel[] {
163
+ return [
164
+ { value: '', label: `Default (${DEFAULT_MODEL})` },
165
+ { value: 'claude-fable-5', label: 'Fable 5 — frontier, most capable' },
166
+ { value: 'claude-opus-4-8', label: 'Opus 4.8 — most capable' },
167
+ { value: 'claude-opus-4-7', label: 'Opus 4.7' },
168
+ { value: 'claude-opus-4-6', label: 'Opus 4.6' },
169
+ { value: 'claude-sonnet-4-6', label: 'Sonnet 4.6 — balanced' },
170
+ { value: 'claude-haiku-4-5', label: 'Haiku 4.5 — fastest' },
171
+ ];
172
+ }
173
+
174
+ async *stream(opts: EngineStreamOpts): AsyncGenerator<WsEvent> {
175
+ const { config, directives } = opts;
176
+ const cwd = PROJECT_ROOT;
177
+
178
+ const fullPrompt = buildHistoryPrompt(opts.conversation, opts.contextBlock, opts.prompt);
179
+ const permMode = opts.onPermissionRequest ? 'default' : (config.permissionMode ?? 'acceptEdits');
180
+
181
+ const sdkEnv: Record<string, string> = {};
182
+ for (const [k, v] of Object.entries(process.env)) {
183
+ if (v !== undefined) sdkEnv[k] = v;
184
+ }
185
+ // Injected for the agent's own tools/scripts. Each is written under both the canonical
186
+ // `SHRAGA_*` name and the legacy `UNCLAW_*` one — deployed workspaces still contain scripts
187
+ // and extensions that read the legacy names, and we don't control those callers.
188
+ sdkEnv.SHRAGA_USER_UID = sdkEnv.UNCLAW_USER_UID = opts.uid;
189
+ if (opts.userEmail) sdkEnv.SHRAGA_USER_EMAIL = sdkEnv.UNCLAW_USER_EMAIL = opts.userEmail;
190
+ sdkEnv.SHRAGA_SESSION_ID = sdkEnv.UNCLAW_SESSION_ID = opts.sessionId ?? '';
191
+ sdkEnv.INTERNAL_API_TOKEN = signInternalToken(opts.uid, opts.userEmail || 'unknown');
192
+
193
+ const baseAllowed = config.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
194
+ const allowedTools = baseAllowed.includes('ToolSearch') ? baseAllowed : [...baseAllowed, 'ToolSearch'];
195
+ const maxTurns = directives.turns ?? config.maxTurns ?? 50;
196
+
197
+ const options: Record<string, unknown> = {
198
+ tools: { type: 'preset', preset: 'claude_code' },
199
+ env: sdkEnv,
200
+ allowedTools,
201
+ cwd,
202
+ permissionMode: permMode === 'bypassPermissions' ? 'acceptEdits' : permMode,
203
+ maxTurns,
204
+ includePartialMessages: true,
205
+ // The SDK `skills` option is a context filter over skills the SDK DISCOVERS on disk
206
+ // (.claude/skills / settingSources / plugins) — NOT our DATA_DIR/skills. We never point the
207
+ // SDK at that dir, so these names match nothing: the built-in `Skill` tool turns on but can
208
+ // resolve zero of them → every Skill{name} returns "Unknown skill". Our real skill path is
209
+ // trigger-injection + the <available-skills> index (which tells the model to `Read` them).
210
+ // Disable the phantom tool so the model doesn't waste a turn (and then fly blind) on it.
211
+ skills: listSkills(),
212
+ disallowedTools: ['Skill'],
213
+ agents: loadAgents(),
214
+ hooks: buildHooks(),
215
+ };
216
+
217
+ const userHandler = opts.onPermissionRequest;
218
+ const destructiveHandler = opts.onDestructiveApproval;
219
+ const questionHandler = opts.onUserQuestion;
220
+ options['canUseTool'] = async (toolName: string, input: Record<string, unknown>) => {
221
+ const denied = checkSensitiveAccess(toolName, input);
222
+ if (denied) return denied;
223
+ if (toolName === 'AskUserQuestion') {
224
+ const questions = (input.questions ?? []) as AskQuestion[];
225
+ const answers = questionHandler
226
+ ? await questionHandler(crypto.randomUUID(), questions).catch((err) => {
227
+ console.error(`[claude] onUserQuestion failed:`, (err as Error)?.message);
228
+ return null;
229
+ })
230
+ : null;
231
+ if (answers && Object.keys(answers).length) {
232
+ return { behavior: 'allow' as const, updatedInput: { ...input, questions, answers } };
233
+ }
234
+ const sentinel: QuestionAnswers = {};
235
+ for (const q of questions) sentinel[q.question] = NO_INTERACTIVE_ANSWER;
236
+ return { behavior: 'allow' as const, updatedInput: { ...input, questions, answers: sentinel } };
237
+ }
238
+ if (isDestructiveDataOp(toolName, input)) {
239
+ const cmd = ((input.command ?? '') as string).slice(0, 100);
240
+ console.log(`[security] Destructive data op requires approval: ${cmd}`);
241
+ if (destructiveHandler) {
242
+ const id = crypto.randomUUID();
243
+ const result = await destructiveHandler(id, toolName, input);
244
+ if (result.allow) return { behavior: 'allow' as const, updatedInput: input };
245
+ return { behavior: 'deny' as const, message: 'User denied this destructive action' };
246
+ }
247
+ return { behavior: 'deny' as const, message: 'Destructive data operations require interactive approval.' };
248
+ }
249
+ if (userHandler) {
250
+ const id = crypto.randomUUID();
251
+ const result = await userHandler(id, toolName, input);
252
+ if (result.allow) return { behavior: 'allow' as const, updatedInput: input };
253
+ return { behavior: 'deny' as const, message: 'User denied this action' };
254
+ }
255
+ return { behavior: 'allow' as const, updatedInput: input };
256
+ };
257
+
258
+ // Always pass an explicit model — left unset, the CLI applies its own default
259
+ // (observed: Opus 4.7), not what the UI's "Default" label promises.
260
+ options['model'] = directives.model ?? config.model ?? DEFAULT_MODEL;
261
+ const thinkingMode = directives.thinking ?? config.thinking;
262
+ if (thinkingMode) options['thinking'] = thinkingMode === 'enabled' ? { type: 'enabled' } : { type: thinkingMode };
263
+ const effort = directives.effort ?? config.effort;
264
+ if (effort) options['effort'] = effort;
265
+
266
+ const userPrompt = config.systemPrompt || DEFAULT_USER_PROMPT;
267
+ const voiceSuffix = ""; // voice mode is an optional add-on; not present in this build
268
+ options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${voiceSuffix}`;
269
+ if (opts.abortController) options['abortController'] = opts.abortController;
270
+ if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) options['mcpServers'] = opts.mcpServers;
271
+
272
+ const mcpNames = opts.mcpServers ? Object.keys(opts.mcpServers) : [];
273
+ const activeModel = (options['model'] as string) || 'default';
274
+ const directivesTag = Object.keys(directives).length ? ` directives=${JSON.stringify(directives)}` : '';
275
+ console.log(`[claude] Starting query user=${opts.uid} session=${opts.sessionId ?? 'new'} model=${activeModel} perms=${config.permissionMode} mcps=[${mcpNames.join(',')}]${directivesTag} cwd=${cwd}`);
276
+ const startTime = Date.now();
277
+ const elapsed = () => `${Date.now() - startTime}ms`;
278
+
279
+ const sessionKey = opts.sessionId ?? crypto.randomUUID();
280
+ const hasAttachments = opts.attachments && opts.attachments.length > 0;
281
+ const hasLegacyImages = opts.images && opts.images.length > 0;
282
+ const prompt = hasAttachments
283
+ ? buildAttachmentPrompt(fullPrompt, opts.attachments!, sessionKey)
284
+ : hasLegacyImages
285
+ ? buildLegacyImagePrompt(fullPrompt, opts.images!, sessionKey)
286
+ : fullPrompt;
287
+
288
+ const q = query({ prompt, options: options as any });
289
+
290
+ let lastSessionId = '';
291
+ let messageCount = 0;
292
+ let textDeltaCount = 0;
293
+ let turnCount = 0;
294
+ const pendingToolUses = new Map<string, { tool: string; input: any }>();
295
+ const streamedToolUseIds = new Set<string>();
296
+ const streamingToolInputsByIndex = new Map<number, { id: string; name: string; inputJson: string }>();
297
+ const outstandingTasks = new Set<string>();
298
+
299
+ const iter = q[Symbol.asyncIterator]();
300
+ let pendingNext: Promise<IteratorResult<any>> | null = null;
301
+ let waitingForBg = false;
302
+ let bgTimer: Promise<'__bgtimeout'> | null = null;
303
+ let bgTimerHandle: ReturnType<typeof setTimeout> | null = null;
304
+ const clearBgTimer = () => { if (bgTimerHandle) { clearTimeout(bgTimerHandle); bgTimerHandle = null; } bgTimer = null; };
305
+
306
+ try {
307
+ while (true) {
308
+ if (!pendingNext) pendingNext = iter.next();
309
+ let res: IteratorResult<any>;
310
+ if (waitingForBg) {
311
+ if (!bgTimer) bgTimer = new Promise((r) => { bgTimerHandle = setTimeout(() => r('__bgtimeout'), BG_TASK_MAX_WAIT_MS); });
312
+ const raced = await Promise.race([pendingNext, bgTimer]);
313
+ if (raced === '__bgtimeout') {
314
+ console.warn(`[claude] Background-task wait timed out (${elapsed()})`);
315
+ yield { type: 'done', sessionId: lastSessionId, stopReason: 'end_turn' };
316
+ return;
317
+ }
318
+ res = raced as IteratorResult<any>;
319
+ } else {
320
+ res = await pendingNext;
321
+ }
322
+ pendingNext = null;
323
+ if (res.done) break;
324
+ const m = res.value as any;
325
+ messageCount++;
326
+
327
+ if (m.session_id && !lastSessionId) {
328
+ lastSessionId = m.session_id;
329
+ console.log(`[claude] Got session_id=${lastSessionId} at msg #${messageCount} (${elapsed()})`);
330
+ }
331
+
332
+ if (m.type === 'system' && m.subtype === 'init') {
333
+ if (m.model) {
334
+ console.log(`[claude] Init model=${m.model}${m.model !== options['model'] ? ` (requested ${options['model']})` : ''}`);
335
+ // If the user explicitly asked to switch models via a [directive], announce the change
336
+ // inline so the response confirms the switch took effect.
337
+ const sw = resolveModelSwitch({
338
+ requested: opts.directives.model ? m.model : undefined,
339
+ current: m.model,
340
+ prior: opts.sessionId ? getSessionModel(opts.sessionId) : undefined,
341
+ });
342
+ if (sw.notice) yield { type: 'text_delta', text: sw.notice };
343
+ if (opts.sessionId) setSessionModel(opts.sessionId, m.model);
344
+ // Live ground-truth so the header pill confirms the actually-resolved model mid-turn
345
+ // (catches inline overrides like [opus] and silent rate-limit fallbacks) instead of
346
+ // only updating on session reload.
347
+ yield { type: 'model_resolved', sessionId: opts.sessionId ?? '', model: m.model };
348
+ }
349
+ const servers = m.mcp_servers;
350
+ if (Array.isArray(servers) && servers.length > 0) {
351
+ console.log(`[claude] MCP: ${servers.map((s: any) => `${s.name}:${s.status}`).join(', ')}`);
352
+ }
353
+ continue;
354
+ }
355
+
356
+ if (m.type === 'system' && m.subtype === 'task_started') {
357
+ outstandingTasks.add(m.task_id);
358
+ console.log(`[claude] Background task started: ${m.task_id} (${outstandingTasks.size} pending) (${elapsed()})`);
359
+ continue;
360
+ }
361
+ if (m.type === 'system' && m.subtype === 'task_notification') {
362
+ outstandingTasks.delete(m.task_id);
363
+ console.log(`[claude] Background task ${m.status}: ${m.task_id} (${outstandingTasks.size} pending) (${elapsed()})`);
364
+ if (outstandingTasks.size === 0) { waitingForBg = false; clearBgTimer(); }
365
+ continue;
366
+ }
367
+
368
+ if (m.type === 'system') continue;
369
+
370
+ if (m.type === 'result') {
371
+ lastSessionId = m.session_id || lastSessionId;
372
+ const sdkTurns = m.num_turns ?? 0;
373
+ const raw = m.subtype ?? 'unknown';
374
+ const sub = raw === 'error_max_turns' || (raw === 'end_turn' && sdkTurns >= maxTurns) ? 'max_turns_reached' : raw;
375
+ console.log(`[claude] Result: subtype=${m.subtype}→${sub} session=${lastSessionId} turns=${sdkTurns}/${maxTurns} cost=$${m.total_cost_usd?.toFixed(4) ?? '?'} msgs=${messageCount} deltas=${textDeltaCount} (${elapsed()})`);
376
+ logCacheUsage(m.usage, activeModel);
377
+ if (outstandingTasks.size > 0) {
378
+ if (!waitingForBg) { waitingForBg = true; clearBgTimer(); console.log(`[claude] Holding stream for ${outstandingTasks.size} bg tasks (${elapsed()})`); }
379
+ continue;
380
+ }
381
+ if (textDeltaCount === 0) {
382
+ console.warn(`[claude] Empty response — result keys: ${Object.keys(m).join(',')}`);
383
+ }
384
+ yield { type: 'done', sessionId: lastSessionId, stopReason: sub };
385
+ return;
386
+ }
387
+
388
+ if (m.type === 'stream_event') {
389
+ const event = m.event;
390
+ if (event?.type === 'content_block_delta' && event.delta?.type === 'thinking_delta') {
391
+ yield { type: 'thinking_delta', text: event.delta.thinking };
392
+ }
393
+ if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
394
+ textDeltaCount++;
395
+ yield { type: 'text_delta', text: event.delta.text };
396
+ }
397
+ if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
398
+ const { id, name } = event.content_block;
399
+ const idx = event.index ?? -1;
400
+ streamedToolUseIds.add(id);
401
+ streamingToolInputsByIndex.set(idx, { id, name, inputJson: '' });
402
+ yield { type: 'tool_use', tool: name, toolUseId: id, input: {} };
403
+ }
404
+ if (event?.type === 'content_block_delta' && event.delta?.type === 'input_json_delta') {
405
+ const entry = streamingToolInputsByIndex.get(event.index ?? -1);
406
+ if (entry) {
407
+ entry.inputJson += event.delta.partial_json ?? '';
408
+ yield { type: 'tool_use_input', toolUseId: entry.id, input: entry.inputJson };
409
+ }
410
+ }
411
+ continue;
412
+ }
413
+
414
+ if (m.type === 'assistant' && Array.isArray(m.message?.content)) {
415
+ turnCount++;
416
+ for (const block of m.message.content) {
417
+ if (block.type === 'tool_use') {
418
+ pendingToolUses.set(block.id, { tool: block.name, input: block.input });
419
+ if (streamedToolUseIds.has(block.id)) {
420
+ yield { type: 'tool_use_input', toolUseId: block.id, input: block.input };
421
+ } else {
422
+ yield { type: 'tool_use', tool: block.name, toolUseId: block.id, input: block.input };
423
+ }
424
+ }
425
+ }
426
+ streamingToolInputsByIndex.clear();
427
+ continue;
428
+ }
429
+
430
+ if (m.type === 'user' && Array.isArray(m.message?.content)) {
431
+ for (const block of m.message.content) {
432
+ if (block.type === 'tool_result') {
433
+ const contentArr = Array.isArray(block.content) ? block.content : [];
434
+ const output = contentArr.length > 0
435
+ ? contentArr.filter((c: any) => c.type === 'text').map((c: any) => c.text ?? '').join('')
436
+ : String(block.content ?? '');
437
+ yield { type: 'tool_result', toolUseId: String(block.tool_use_id), output };
438
+
439
+ let foundImage = false;
440
+ for (const c of contentArr) {
441
+ if (c.type === 'image') {
442
+ foundImage = true;
443
+ if (c.source?.type === 'base64' && c.source?.data) {
444
+ yield { type: 'tool_result_image', toolUseId: String(block.tool_use_id), dataUrl: `data:${c.source.media_type || 'image/png'};base64,${c.source.data}` };
445
+ } else if (c.data && c.mimeType) {
446
+ yield { type: 'tool_result_image', toolUseId: String(block.tool_use_id), dataUrl: `data:${c.mimeType};base64,${c.data}` };
447
+ }
448
+ }
449
+ }
450
+ if (!foundImage && output.includes('"dataUrl":"data:')) {
451
+ try {
452
+ const parsed = JSON.parse(output);
453
+ if (parsed.dataUrl?.startsWith('data:')) {
454
+ yield { type: 'tool_result_image', toolUseId: String(block.tool_use_id), dataUrl: parsed.dataUrl };
455
+ }
456
+ } catch (e) { console.warn('[claude] Failed to parse dataUrl from tool result text', e); }
457
+ }
458
+
459
+ const pending = pendingToolUses.get(String(block.tool_use_id));
460
+ if (pending?.tool === 'mcp__mcp-slack__post_slack_message') {
461
+ try {
462
+ const parsed = typeof output === 'string' ? JSON.parse(output) : output;
463
+ const ts = parsed?.ts || parsed?.preview?.ts;
464
+ const inp = pending.input as any;
465
+ const channel = inp?.body?.channel || inp?.channel_id || inp?.channel;
466
+ const sid = opts.sessionId || lastSessionId;
467
+ if (ts && channel && sid) {
468
+ const session = getSession(sid);
469
+ registerProactiveMessage(channel, ts, sid, session?.title || sid);
470
+ }
471
+ } catch (err) { console.warn('[claude] Failed to track proactive message:', (err as Error).message); }
472
+ }
473
+ if (pending?.tool === 'mcp__mcp-slack__post_slack_poll') {
474
+ try {
475
+ const parsed = typeof output === 'string' ? JSON.parse(output) : output;
476
+ const body = ((pending.input as any)?.body ?? {}) as Record<string, any>;
477
+ const sid = opts.sessionId || lastSessionId;
478
+ if (parsed?.ok && parsed.pollId && parsed.ts && parsed.channel && sid) {
479
+ registerPoll({
480
+ pollId: parsed.pollId, channel: parsed.channel, ts: parsed.ts, title: String(body.title ?? ''),
481
+ options: parsed.options ?? body.options ?? [], kind: parsed.kind ?? 'poll',
482
+ multi: parsed.multi, targetUser: parsed.targetUser,
483
+ deadlineMinutes: typeof body.deadline_minutes === 'number' ? body.deadline_minutes : undefined,
484
+ quorum: typeof body.quorum === 'number' ? body.quorum : undefined,
485
+ sessionId: sid, uid: opts.uid, userEmail: opts.userEmail,
486
+ });
487
+ }
488
+ } catch (err) { console.warn('[claude] Failed to register poll:', (err as Error).message); }
489
+ }
490
+ pendingToolUses.delete(String(block.tool_use_id));
491
+ }
492
+ }
493
+ continue;
494
+ }
495
+ }
496
+ } catch (err: any) {
497
+ if (err.name === 'AbortError' || opts.abortController?.signal.aborted) {
498
+ console.log(`[claude] Stream aborted after ${messageCount} msgs (${elapsed()})`);
499
+ return;
500
+ }
501
+ console.error(`[claude] Error after ${messageCount} msgs (${elapsed()}):`, err.message || err);
502
+ yield { type: 'error', message: err.message || String(err) };
503
+ return;
504
+ } finally {
505
+ clearBgTimer();
506
+ }
507
+
508
+ const inferredReason = turnCount >= maxTurns ? 'max_turns_reached' : 'end_turn';
509
+ console.warn(`[claude] Stream ended without result. session=${lastSessionId || 'none'} msgs=${messageCount} turns=${turnCount}/${maxTurns} (${elapsed()})`);
510
+ if (lastSessionId) {
511
+ yield { type: 'done', sessionId: lastSessionId, stopReason: inferredReason };
512
+ }
513
+ }
514
+ }
@@ -0,0 +1,41 @@
1
+ export type { AgentEngine, EngineStreamOpts, EngineModel } from './types.ts';
2
+ export { registerEngine, getEngine, getAvailableEngines, hasEngine } from './registry.ts';
3
+ export { ClaudeCodeEngine } from './claude-code.ts';
4
+
5
+ import { registerEngine, getEngine, getAvailableEngines, hasEngine } from './registry.ts';
6
+ import { ClaudeCodeEngine } from './claude-code.ts';
7
+
8
+ let _initialized = false;
9
+
10
+ export async function initEngines(): Promise<void> {
11
+ if (_initialized) return;
12
+ _initialized = true;
13
+
14
+ // Core always registers the Claude Code engine (the CE default — @anthropic-ai/claude-agent-sdk).
15
+ // Optional engines are registered by an add-on through the same `registerEngine` seam when the
16
+ // SHRAGA_OVERLAY loads (it's imported before the server serves any turn). Bare CE runs Claude Code
17
+ // only; a directive requesting an unregistered engine falls back to claude-code (resolveAndGetEngine).
18
+ registerEngine(new ClaudeCodeEngine());
19
+
20
+ console.log(`[engine] Available engines: ${getAvailableEngines().join(', ')}`);
21
+ }
22
+
23
+ /** Resolve which engine to use: directive > agent-config.json > default */
24
+ export function resolveEngine(directives?: { engine?: string }, agentConfig?: { engine?: string }): string {
25
+ if (directives?.engine) return directives.engine;
26
+ if (agentConfig?.engine) return agentConfig.engine;
27
+ return 'claude-code';
28
+ }
29
+
30
+ export function resolveAndGetEngine(directives?: { engine?: string }, agentConfig?: { engine?: string }) {
31
+ const name = resolveEngine(directives, agentConfig);
32
+ // An optional engine may be unregistered on a given boot (add-on not loaded, missing API key or
33
+ // failed init). Don't let that throw and kill every run — including scheduled jobs like the daily
34
+ // digest, which resolve the engine from the global agent-config. Fall back to the always-present
35
+ // claude-code engine with a warning instead.
36
+ if (!hasEngine(name)) {
37
+ console.warn(`[engine] "${name}" not registered (available: ${getAvailableEngines().join(', ') || 'none'}) — falling back to claude-code`);
38
+ return getEngine('claude-code');
39
+ }
40
+ return getEngine(name);
41
+ }
@@ -0,0 +1,21 @@
1
+ import type { AgentEngine } from './types.ts';
2
+
3
+ const engines = new Map<string, AgentEngine>();
4
+
5
+ export function registerEngine(engine: AgentEngine): void {
6
+ engines.set(engine.name, engine);
7
+ }
8
+
9
+ export function getEngine(name: string): AgentEngine {
10
+ const engine = engines.get(name);
11
+ if (!engine) throw new Error(`Unknown engine: ${name}. Available: ${[...engines.keys()].join(', ')}`);
12
+ return engine;
13
+ }
14
+
15
+ export function getAvailableEngines(): string[] {
16
+ return [...engines.keys()];
17
+ }
18
+
19
+ export function hasEngine(name: string): boolean {
20
+ return engines.has(name);
21
+ }