@aion0/forge 0.5.23 → 0.5.25

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.
@@ -32,7 +32,7 @@ interface AgentConfig {
32
32
  }
33
33
 
34
34
  interface AgentState {
35
- smithStatus: 'down' | 'active';
35
+ smithStatus: 'down' | 'starting' | 'active';
36
36
  taskStatus: 'idle' | 'running' | 'done' | 'failed';
37
37
  currentStep?: number;
38
38
  tmuxSession?: string;
@@ -69,98 +69,100 @@ const TASK_STATUS: Record<string, { label: string; color: string; glow?: boolean
69
69
 
70
70
  const PRESET_AGENTS: Omit<AgentConfig, 'id'>[] = [
71
71
  {
72
- label: 'PM', icon: '🎯', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/prd.md'],
73
- role: `Product Manager — You own the requirements. Your job is to deeply understand the project context, analyze user needs, and produce a clear, actionable PRD.
72
+ label: 'Lead', icon: '👑', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/lead/'],
73
+ primary: true, persistentSession: true, plugins: ['playwright', 'shell-command'],
74
+ role: `Lead — primary coordinator (recommended for Primary smith). Context auto-includes Workspace Team (all agents, roles, status, missing roles).
74
75
 
75
- Rules:
76
- - NEVER write code or implementation details
77
- - Focus on WHAT and WHY, not HOW
78
- - Be specific: include user stories, acceptance criteria, edge cases, and priorities (P0/P1/P2)
79
- - Reference existing codebase structure when relevant
80
- - If requirements are unclear, list assumptions explicitly
81
- - PRD format: SummaryGoals User StoriesAcceptance CriteriaOut of ScopeOpen Questions`,
76
+ SOP: Intake → HAS Architect? delegate via create_request : break down yourself → HAS Engineer? create_request(open) : implement in src/ → HAS QA? auto-notified : test yourself → HAS Reviewer? auto-notified : review yourself.
77
+
78
+ SOP: Monitor get_status + list_requests stuck/failed agents: send_message or take over → unclaimed requests: nudge Engineers.
79
+
80
+ SOP: Quality Gate ALL requests done + review=approved + qa=passed → write docs/lead/delivery-summary.md.
81
+
82
+ Gap coverage: missing PM you break requirements; missing Engineer you code; missing QA you test; missing Reviewer you review. Every delegation uses create_request with acceptance_criteria.`,
82
83
  steps: [
83
- { id: 'research', label: 'Research', prompt: 'Read the project README, existing docs, and codebase structure. Understand the current state, tech stack, and conventions. List what you found.' },
84
- { id: 'analyze', label: 'Analyze Requirements', prompt: 'Based on the input requirements and your research, identify all user stories. For each story, define acceptance criteria. Classify priority as P0 (must have), P1 (should have), P2 (nice to have). List any assumptions and open questions.' },
85
- { id: 'write-prd', label: 'Write PRD', prompt: 'Write a comprehensive PRD to docs/prd.md. Include: Executive Summary, Goals & Non-Goals, User Stories with Acceptance Criteria, Technical Constraints, Dependencies, Out of Scope, Open Questions. Be specific enough that an engineer can implement without asking questions.' },
86
- { id: 'self-review', label: 'Self-Review', prompt: 'Review your PRD critically. Check: Are acceptance criteria testable? Are edge cases covered? Is scope clear? Are priorities justified? Revise if needed.' },
84
+ { id: 'intake', label: 'Intake & Analyze', prompt: 'Read Workspace Team in context. Identify present/missing roles and incoming requirements. Classify scope and plan delegation vs self-handling.' },
85
+ { id: 'delegate', label: 'Create Requests & Route', prompt: 'create_request for each task with acceptance_criteria. Route to Architect/Engineer or note for self-implementation. Verify with list_requests.' },
86
+ { id: 'cover-gaps', label: 'Cover Missing Roles', prompt: 'Implement/test/review for any missing role. update_response for each section you cover.' },
87
+ { id: 'monitor', label: 'Monitor & Unblock', prompt: 'get_status + list_requests. Unblock stuck agents via send_message or take over their work.' },
88
+ { id: 'gate', label: 'Quality Gate & Summary', prompt: 'Verify all requests done/approved/passed. Write docs/lead/delivery-summary.md.' },
87
89
  ],
88
90
  },
89
91
  {
90
- label: 'Engineer', icon: '🔨', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['src/', 'docs/architecture.md'],
91
- role: `Senior Software Engineer — You design and implement features based on the PRD. You write production-quality code.
92
+ label: 'PM', icon: '📋', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/prd/'],
93
+ role: `Product Manager. Context auto-includes Workspace Team.
92
94
 
93
- Rules:
94
- - Read the PRD thoroughly before writing any code
95
- - Design before implement: write architecture doc first
96
- - Follow existing codebase conventions (naming, structure, patterns)
97
- - Write clean, maintainable code with proper error handling
98
- - Add inline comments only where logic isn't self-evident
99
- - Run tests after implementation to catch obvious issues
100
- - Commit atomically: one logical change per step
101
- - If the PRD is unclear, make a reasonable decision and document it`,
95
+ SOP: Read upstream input → list docs/prd/ for version history → identify NEW vs covered → create NEW versioned PRD (never overwrite).
96
+
97
+ PRD structure: version + date, summary, goals, user stories with testable acceptance_criteria, constraints, out of scope, open questions.
98
+
99
+ Version: patch (v1.0.1) = clarification, minor (v1.1) = new feature, major (v2.0) = scope overhaul.
100
+
101
+ Handoff: Do NOT create request docs or write code. Architect/Lead reads docs/prd/ downstream.`,
102
+ steps: [
103
+ { id: 'analyze', label: 'Analyze Requirements', prompt: 'Read Workspace Team. Read upstream input. List docs/prd/ for version history. Identify NEW vs already covered requirements. Decide version number.' },
104
+ { id: 'write-prd', label: 'Write PRD', prompt: 'Create NEW versioned file in docs/prd/. Include testable acceptance criteria for every user story. Never overwrite existing PRD files.' },
105
+ { id: 'self-review', label: 'Self-Review', prompt: 'Checklist: criteria testable by QA? Edge cases? Scope clear for Engineer? No duplication? Fix issues.' },
106
+ ],
107
+ },
108
+ {
109
+ label: 'Engineer', icon: '🔨', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['src/', 'docs/architecture/'],
110
+ role: `Senior Software Engineer. Context auto-includes Workspace Team.
111
+
112
+ SOP: Find Work → list_requests(status: "open") → claim_request → get_request for details.
113
+ SOP: Implement → read acceptance_criteria → design (docs/architecture/) → code (src/) → self-test.
114
+ SOP: Report → update_response(section: "engineer", data: {files_changed, notes}) → auto-notifies QA/Reviewer.
115
+
116
+ IF claim fails (already taken) → pick next open request.
117
+ IF blocked by unclear requirement → send_message to upstream (Architect/PM/Lead) with specific question.
118
+ IF no open requests → check inbox for direct assignments.
119
+
120
+ Rules: always claim before starting, always update_response when done, follow existing conventions, architecture docs versioned (never overwrite).`,
102
121
  steps: [
103
- { id: 'design', label: 'Architecture', prompt: 'Read the PRD in docs/prd.md. Analyze the existing codebase structure and patterns. Design the architecture: what files to create/modify, data flow, interfaces, error handling strategy. Write docs/architecture.md with diagrams (ASCII or markdown) where helpful.' },
104
- { id: 'implement', label: 'Implement', prompt: 'Implement the features based on your architecture doc. Follow existing code conventions. Handle errors properly. Add types/interfaces. Keep functions focused and testable. Create/modify files as planned.' },
105
- { id: 'self-test', label: 'Self-Test', prompt: 'Review your implementation: check for bugs, missing error handling, edge cases, and convention violations. Run any existing tests. Fix issues you find. Do a final git diff review.' },
122
+ { id: 'claim', label: 'Find & Claim', prompt: 'Read Workspace Team. Check inbox. list_requests(status: "open"). claim_request on highest priority. If none, check inbox.' },
123
+ { id: 'design', label: 'Design', prompt: 'get_request for details. Read acceptance_criteria. Read existing code + docs/architecture/. Create new architecture doc if significant change.' },
124
+ { id: 'implement', label: 'Implement', prompt: 'Implement per design. Follow conventions. Track files changed. Run existing tests. Verify against each acceptance_criterion.' },
125
+ { id: 'report', label: 'Report Done', prompt: 'update_response(section: "engineer") with files_changed and notes. If blocked, send_message upstream.' },
106
126
  ],
107
127
  },
108
128
  {
109
- label: 'QA', icon: '🧪', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['tests/', 'docs/test-report.md'],
129
+ label: 'QA', icon: '🧪', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['tests/', 'docs/qa/'],
110
130
  plugins: ['playwright', 'shell-command'],
111
- role: `QA Engineer — You ensure quality through comprehensive testing. You find bugs, you don't fix them.
131
+ role: `QA Engineer. Context auto-includes Workspace Team.
112
132
 
113
- Rules:
114
- - NEVER fix bugs yourself only report them clearly
115
- - Test against PRD acceptance criteria, not assumptions
116
- - Write both happy path and edge case tests
117
- - Include integration tests, not just unit tests
118
- - Run ALL tests (existing + new) and report results
119
- - Report format: what failed, expected vs actual, steps to reproduce
120
- - Check for security issues: injection, auth bypass, data leaks
121
- - Check for performance: N+1 queries, unbounded loops, memory leaks
122
-
123
- Test setup (do this automatically if not already present):
124
- - If no playwright.config.ts exists, create one based on the project's framework and structure
125
- - If no tests/e2e/ directory exists, create it
126
- - Detect the app's dev server command and base URL from package.json or project config
127
- - Ensure the dev server is running before testing (start it if needed)
128
-
129
- How to run Playwright tests:
130
- - Run tests: npx playwright test tests/e2e/ --reporter=line
131
- - Run headed (visible browser): npx playwright test tests/e2e/ --headed --reporter=line
132
- - Screenshot: npx playwright screenshot http://localhost:3000 /tmp/screenshot.png
133
- - Check URL: curl -sf -o /dev/null -w '%{http_code}' http://localhost:3000
134
- - Run project tests: npm test
135
-
136
- If Forge MCP tools are available (run_plugin, send_message), prefer those.
137
- Otherwise use bash commands directly — you have full terminal access.`,
133
+ SOP: Find Work → list_requests(status: "qa") → get_request → read acceptance_criteria + engineer's files_changed.
134
+ SOP: Test map each criterion to test cases → write Playwright tests in tests/e2e/ → run via run_plugin or npx playwright.
135
+ SOP: Report update_response(section: "qa", data: {result, test_files, findings}).
136
+
137
+ IF result=passed auto-advances, no message needed.
138
+ IF result=failed classify: CRITICAL/MAJOR ONE send_message to Engineer. MINOR → report only, no message.
139
+
140
+ Rules: never fix bugs (report only), each test traces to acceptance_criterion, max 1 consolidated message, no messages during planning/writing steps.`,
138
141
  steps: [
139
- { id: 'setup', label: 'Setup', prompt: 'Check the project structure. If playwright.config.ts does not exist, create one with testDir: "./tests/e2e" and baseURL from the project config. Create tests/e2e/ directory if missing. Check if the dev server is running (use check_url), start it if not.' },
140
- { id: 'plan', label: 'Test Plan', prompt: 'Read the source code and any PRD/docs. Understand what pages and features exist. Create a test plan in docs/test-plan.md covering: E2E user flows, edge cases, error states, responsive behavior. Map each test to a feature.' },
141
- { id: 'write-tests', label: 'Write Tests', prompt: 'Write Playwright test scripts in tests/e2e/. Cover all features from the test plan. Use page.goto(), locators, assertions. Test both happy path and error states. Use descriptive test names.' },
142
- { id: 'run-tests', label: 'Run & Report', prompt: 'Run tests via run_plugin with Playwright. If tests fail, read the error output carefully. Fix test scripts if the test itself is wrong, but report to Engineer if the app has a bug. Write docs/test-report.md with results. Send bug reports to Engineer via send_message.' },
142
+ { id: 'find-work', label: 'Find Work', prompt: 'Read Workspace Team. Check inbox. list_requests(status: "qa"). get_request for acceptance_criteria and engineer notes.' },
143
+ { id: 'plan', label: 'Test Plan', prompt: 'Map each criterion to test cases (happy path + edge + error). Write docs/qa/test-plan. Skip already-tested unchanged features.' },
144
+ { id: 'write-tests', label: 'Write Tests', prompt: 'Write Playwright tests in tests/e2e/. Create config if missing. No messages in this step.' },
145
+ { id: 'execute', label: 'Execute & Report', prompt: 'Run tests. Record pass/fail per criterion. update_response(section: qa). If critical/major failures: ONE send_message to Engineer.' },
143
146
  ],
144
147
  },
145
148
  {
146
- label: 'Reviewer', icon: '👁', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/review.md'],
147
- role: `Senior Code Reviewer — You review code for quality, security, maintainability, and correctness. You are the last gate before merge.
149
+ label: 'Reviewer', icon: '🔍', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/review/'],
150
+ role: `Code Reviewer. Context auto-includes Workspace Team.
148
151
 
149
- Rules:
150
- - NEVER modify code only review and report
151
- - Check against PRD requirements: is everything implemented?
152
- - Review architecture decisions: are they sound?
153
- - Check code quality: readability, naming, DRY, error handling
154
- - Check security: OWASP top 10, input validation, auth, secrets exposure
155
- - Check performance: complexity, queries, caching, memory usage
156
- - Check test coverage: are critical paths tested?
157
- - Rate severity: CRITICAL (must fix) / MAJOR (should fix) / MINOR (nice to fix)
158
- - Give actionable feedback: not just "this is bad" but "change X to Y because Z"`,
152
+ SOP: Find Work → list_requests(status: "review") → get_request → read request + engineer response + QA results.
153
+ SOP: Review each file in files_changed → check: criteria met? code quality? security (OWASP)? performance? → classify CRITICAL/MAJOR/MINOR.
154
+ SOP: Verdict approved (all good) / changes_requested (issues) / rejected (security/data).
155
+ SOP: Report update_response(section: "review", data: {result, findings}) → write docs/review/.
156
+
157
+ IF approved auto-advances to done, no message.
158
+ IF changes_requested ONE send_message to Engineer with top issues.
159
+ IF rejected send_message to Engineer AND Lead.
160
+
161
+ Rules: never modify code, review only files_changed (not entire codebase), actionable feedback ("change X to Y because Z"), MINOR findings in report only.`,
159
162
  steps: [
160
- { id: 'review-arch', label: 'Architecture Review', prompt: 'Read docs/prd.md and docs/architecture.md. Evaluate: Does the architecture satisfy all PRD requirements? Are there design flaws, scalability issues, or over-engineering? Document findings.' },
161
- { id: 'review-code', label: 'Code Review', prompt: 'Review all changed/new files. For each file check: correctness, error handling, security (injection, auth, secrets), performance (N+1, unbounded), naming conventions, code duplication, edge cases. Use git diff to see exact changes.' },
162
- { id: 'review-tests', label: 'Test Review', prompt: 'Review docs/test-report.md and test code. Check: Are all PRD acceptance criteria covered by tests? Are tests meaningful (not just asserting true)? Are edge cases tested? Any flaky test risks?' },
163
- { id: 'report', label: 'Final Report', prompt: 'Write docs/review.md: Summary verdict (APPROVE / REQUEST_CHANGES / REJECT). List all findings grouped by severity (CRITICAL → MAJOR → MINOR). For each: file, line, issue, suggested fix. End with an overall assessment and recommendation.' },
163
+ { id: 'find-work', label: 'Find Work', prompt: 'Read Workspace Team. Check inbox. list_requests(status: "review"). get_request for full context.' },
164
+ { id: 'review', label: 'Code Review', prompt: 'Review each file in files_changed: criteria met? quality? security? performance? Classify CRITICAL/MAJOR/MINOR.' },
165
+ { id: 'report', label: 'Verdict & Report', prompt: 'Decide verdict. update_response(section: review). Write docs/review/. If changes_requested/rejected: ONE message to Engineer (+ Lead if rejected).' },
164
166
  ],
165
167
  },
166
168
  {
@@ -634,6 +636,10 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
634
636
  }));
635
637
  setAvailableAgents(list);
636
638
  }).catch(() => {});
639
+ // Fetch saved smith templates
640
+ fetch('/api/smith-templates').then(r => r.json()).then(data => {
641
+ setSavedTemplates(data.templates || []);
642
+ }).catch(() => {});
637
643
  // Fetch both: plugin definitions + installed instances
638
644
  Promise.all([
639
645
  fetch('/api/plugins').then(r => r.json()),
@@ -667,6 +673,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
667
673
  const [watchSendTo, setWatchSendTo] = useState(initial.watch?.sendTo || '');
668
674
  const [selectedPlugins, setSelectedPlugins] = useState<string[]>(initial.plugins || []);
669
675
  const [recommendedTypes, setRecommendedTypes] = useState<string[]>([]);
676
+ const [savedTemplates, setSavedTemplates] = useState<{ id: string; name: string; icon: string; description?: string; config: any }[]>([]);
670
677
  const [watchDebounce, setWatchDebounce] = useState(String(initial.watch?.targets?.[0]?.debounce ?? 10));
671
678
  const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string; pattern?: string }[]>(
672
679
  initial.watch?.targets || []
@@ -702,7 +709,53 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
702
709
  setOutputs(p.outputs.join(', '));
703
710
  setStepsText(p.steps.map(s => `${s.label}: ${s.prompt}`).join('\n'));
704
711
  setRecommendedTypes(p.plugins || []);
705
- setSelectedPlugins([]);
712
+ setSelectedPlugins(p.plugins || []);
713
+ if (p.persistentSession !== undefined) setPersistentSession(!!p.persistentSession);
714
+ if (p.skipPermissions !== undefined) setSkipPermissions(p.skipPermissions !== false);
715
+ if (p.requiresApproval !== undefined) setRequiresApproval(!!p.requiresApproval);
716
+ if (p.model) setAgentModel(p.model);
717
+ if (p.watch) {
718
+ setWatchEnabled(!!p.watch.enabled);
719
+ setWatchInterval(String(p.watch.interval || 60));
720
+ setWatchAction(p.watch.action || 'log');
721
+ setWatchPrompt(p.watch.prompt || '');
722
+ setWatchSendTo(p.watch.sendTo || '');
723
+ setWatchTargets(p.watch.targets || []);
724
+ setWatchDebounce(String(p.watch.targets?.[0]?.debounce ?? 10));
725
+ }
726
+ };
727
+
728
+ const applySavedTemplate = (t: { config: any }) => {
729
+ const c = t.config;
730
+ applyPreset({
731
+ label: c.label || '', icon: c.icon || '🤖', role: c.role || '',
732
+ backend: c.backend || 'cli', agentId: c.agentId, dependsOn: [],
733
+ workDir: c.workDir || './', outputs: c.outputs || [],
734
+ steps: c.steps || [], plugins: c.plugins,
735
+ persistentSession: c.persistentSession, skipPermissions: c.skipPermissions,
736
+ requiresApproval: c.requiresApproval, model: c.model,
737
+ watch: c.watch,
738
+ } as any);
739
+ };
740
+
741
+ const handleImportFile = () => {
742
+ const input = document.createElement('input');
743
+ input.type = 'file';
744
+ input.accept = '.json';
745
+ input.onchange = async (e) => {
746
+ const file = (e.target as HTMLInputElement).files?.[0];
747
+ if (!file) return;
748
+ try {
749
+ const text = await file.text();
750
+ const data = JSON.parse(text);
751
+ // Support both raw config and template wrapper
752
+ const config = data.config || data;
753
+ applySavedTemplate({ config });
754
+ } catch {
755
+ alert('Invalid template file');
756
+ }
757
+ };
758
+ input.click();
706
759
  };
707
760
 
708
761
  const toggleDep = (id: string) => {
@@ -731,15 +784,16 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
731
784
  </div>
732
785
 
733
786
  <div className="flex flex-col gap-2.5">
734
- {/* Preset quick-select (add mode only) */}
787
+ {/* Preset + saved templates (add mode only) */}
735
788
  {mode === 'add' && (
736
789
  <div className="flex flex-col gap-1">
737
- <label className="text-[9px] text-gray-500 uppercase">Template</label>
790
+ <label className="text-[9px] text-gray-500 uppercase">Presets</label>
738
791
  <div className="flex gap-1 flex-wrap">
739
792
  {PRESET_AGENTS.map((p, i) => (
740
793
  <button key={i} onClick={() => applyPreset(p)}
741
- className={`text-[9px] px-2 py-1 rounded border transition-colors ${label === p.label ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10' : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
742
- {p.icon} {p.label}
794
+ title={p.primary ? 'Recommended for Primary smith (runs at project root, coordinates others)' : p.label}
795
+ className={`text-[9px] px-2 py-1 rounded border transition-colors ${label === p.label ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10' : p.primary ? 'border-[#f0883e]/40 text-[#f0883e] hover:border-[#f0883e]' : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
796
+ {p.icon} {p.label}{p.primary ? ' ★' : ''}
743
797
  </button>
744
798
  ))}
745
799
  <button onClick={() => { setLabel(''); setIcon('🤖'); setRole(''); setStepsText(''); setOutputs(''); }}
@@ -747,6 +801,22 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
747
801
  Custom
748
802
  </button>
749
803
  </div>
804
+ {savedTemplates.length > 0 && (<>
805
+ <label className="text-[9px] text-gray-500 uppercase mt-1">Saved Templates</label>
806
+ <div className="flex gap-1 flex-wrap">
807
+ {savedTemplates.map(t => (
808
+ <button key={t.id} onClick={() => applySavedTemplate(t)}
809
+ className={`text-[9px] px-2 py-1 rounded border transition-colors ${label === t.config?.label ? 'border-[#f0883e] text-[#f0883e] bg-[#f0883e]/10' : 'border-[#30363d] text-gray-400 hover:text-white'}`}
810
+ title={t.description || t.name}>
811
+ {t.icon} {t.name}
812
+ </button>
813
+ ))}
814
+ </div>
815
+ </>)}
816
+ <button onClick={handleImportFile}
817
+ className="text-[9px] px-2 py-1 rounded border border-dashed border-[#30363d] text-gray-500 hover:text-white hover:border-gray-400 self-start mt-0.5">
818
+ 📂 Import from file
819
+ </button>
750
820
  </div>
751
821
  )}
752
822
 
@@ -1176,6 +1246,26 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
1176
1246
  </div>
1177
1247
 
1178
1248
  <div className="flex justify-end gap-2 mt-4">
1249
+ {mode === 'edit' && (
1250
+ <button onClick={() => {
1251
+ const config = {
1252
+ label: label.trim(), icon: icon.trim() || '🤖', role: role.trim(),
1253
+ backend, agentId, workDir: workDirVal.trim() || './',
1254
+ outputs: outputs.split(',').map(s => s.trim()).filter(Boolean),
1255
+ steps: parseSteps(), plugins: selectedPlugins.length > 0 ? selectedPlugins : undefined,
1256
+ persistentSession: persistentSession || undefined, skipPermissions: persistentSession ? (skipPermissions ? undefined : false) : undefined,
1257
+ model: agentModel || undefined, requiresApproval: requiresApproval || undefined,
1258
+ watch: watchEnabled && watchTargets.length > 0 ? { enabled: true, interval: Math.max(10, parseInt(watchInterval) || 60), targets: watchTargets.map(t => ({ ...t, debounce: parseInt(watchDebounce) || 10 })), action: watchAction, prompt: watchPrompt || undefined, sendTo: watchSendTo || undefined } : undefined,
1259
+ };
1260
+ const blob = new Blob([JSON.stringify({ config, name: label.trim(), icon: icon.trim() || '🤖', exportedAt: Date.now() }, null, 2)], { type: 'application/json' });
1261
+ const url = URL.createObjectURL(blob);
1262
+ const a = document.createElement('a');
1263
+ a.href = url; a.download = `smith-${label.trim().toLowerCase().replace(/\s+/g, '-')}.json`; a.click();
1264
+ URL.revokeObjectURL(url);
1265
+ }} className="text-xs px-3 py-1.5 rounded border border-[#30363d] text-gray-400 hover:text-white mr-auto" title="Export config as file">
1266
+ 📤 Export
1267
+ </button>
1268
+ )}
1179
1269
  <button onClick={onCancel} className="text-xs px-3 py-1.5 rounded border border-[#30363d] text-gray-400 hover:text-white">Cancel</button>
1180
1270
  <button disabled={!label.trim()} onClick={() => {
1181
1271
  onConfirm({
@@ -2501,6 +2591,8 @@ interface AgentNodeData {
2501
2591
  onShowInbox: () => void;
2502
2592
  onOpenTerminal: () => void;
2503
2593
  onSwitchSession: () => void;
2594
+ onSaveAsTemplate: () => void;
2595
+ mascotTheme: MascotTheme;
2504
2596
  onMarkIdle?: () => void;
2505
2597
  onMarkDone?: (notify: boolean) => void;
2506
2598
  onMarkFailed?: (notify: boolean) => void;
@@ -2512,8 +2604,634 @@ interface AgentNodeData {
2512
2604
  // PortalTerminal/NodeTerminal removed — xterm cannot render inside React Flow nodes
2513
2605
  // and createPortal causes event routing issues. Using FloatingTerminal instead.
2514
2606
 
2607
+ // ─── Worker Mascot — SVG stick figure with pose-based animations ──────────────
2608
+ const MASCOT_STYLES = `
2609
+ @keyframes mascot-sleep {
2610
+ 0%, 100% { transform: translateY(0) rotate(-3deg); opacity: 0.6; }
2611
+ 50% { transform: translateY(-2px) rotate(3deg); opacity: 0.9; }
2612
+ }
2613
+ @keyframes mascot-work {
2614
+ 0%, 100% { transform: translateY(0) rotate(0deg); }
2615
+ 25% { transform: translateY(-2px) rotate(-6deg); }
2616
+ 50% { transform: translateY(0) rotate(0deg); }
2617
+ 75% { transform: translateY(-2px) rotate(6deg); }
2618
+ }
2619
+ @keyframes mascot-celebrate {
2620
+ 0% { transform: translateY(0) scale(1); }
2621
+ 12% { transform: translateY(-6px) scale(1.15) rotate(-10deg); }
2622
+ 25% { transform: translateY(-3px) scale(1.1) rotate(0deg); }
2623
+ 37% { transform: translateY(-6px) scale(1.15) rotate(10deg); }
2624
+ 50% { transform: translateY(0) scale(1) rotate(0deg); }
2625
+ 100% { transform: translateY(0) scale(1) rotate(0deg); }
2626
+ }
2627
+ @keyframes mascot-fall {
2628
+ 0% { transform: translateY(0) rotate(0deg); }
2629
+ 30% { transform: translateY(2px) rotate(-15deg); }
2630
+ 60% { transform: translateY(4px) rotate(-90deg); }
2631
+ 100% { transform: translateY(4px) rotate(-90deg); opacity: 0.6; }
2632
+ }
2633
+ @keyframes mascot-idle {
2634
+ 0%, 100% { transform: translateY(0); }
2635
+ 50% { transform: translateY(-1px); }
2636
+ }
2637
+ @keyframes mascot-blink { 0%, 95%, 100% { opacity: 1; } 97% { opacity: 0.3; } }
2638
+ @keyframes stick-arm-hammer {
2639
+ 0%, 100% { transform: rotate(-40deg); }
2640
+ 50% { transform: rotate(20deg); }
2641
+ }
2642
+ @keyframes stick-arm-wave {
2643
+ 0%, 100% { transform: rotate(-120deg); }
2644
+ 50% { transform: rotate(-150deg); }
2645
+ }
2646
+ @keyframes stick-leg-walk-l {
2647
+ 0%, 100% { transform: rotate(-10deg); }
2648
+ 50% { transform: rotate(10deg); }
2649
+ }
2650
+ @keyframes stick-leg-walk-r {
2651
+ 0%, 100% { transform: rotate(10deg); }
2652
+ 50% { transform: rotate(-10deg); }
2653
+ }
2654
+ @keyframes stick-zzz {
2655
+ 0% { opacity: 0; transform: translate(0, 0) scale(0.5); }
2656
+ 50% { opacity: 1; transform: translate(4px, -6px) scale(1); }
2657
+ 100% { opacity: 0; transform: translate(8px, -12px) scale(1.2); }
2658
+ }
2659
+ @keyframes stick-spark {
2660
+ 0%, 100% { opacity: 0; }
2661
+ 50% { opacity: 1; }
2662
+ }
2663
+ @keyframes stick-spark-burst {
2664
+ 0% { opacity: 0; transform: scale(0.5); }
2665
+ 30% { opacity: 1; transform: scale(1.2); }
2666
+ 70% { opacity: 1; transform: scale(1); }
2667
+ 100% { opacity: 0; transform: scale(0.8); }
2668
+ }
2669
+ `;
2670
+ type MascotPose = 'idle' | 'work' | 'done' | 'fail' | 'sleep' | 'wake';
2671
+ export type MascotTheme = 'off' | 'stick' | 'cat' | 'dog' | 'pig' | 'emoji';
2672
+
2673
+ function StickCat({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
2674
+ const strokeProps = { stroke: color, strokeWidth: 1.5, strokeLinecap: 'round' as const, fill: 'none' };
2675
+ const body = (tailAnim: string) => (
2676
+ <>
2677
+ {/* head */}
2678
+ <circle cx="10" cy="18" r="5" stroke={color} strokeWidth="1.5" fill="none" />
2679
+ {/* ears */}
2680
+ <path d="M 6 15 L 7 11 L 10 14 Z" fill={color} />
2681
+ <path d="M 14 15 L 13 11 L 10 14 Z" fill={color} />
2682
+ {/* eyes */}
2683
+ <circle cx="8" cy="18" r="0.8" fill={accentColor} />
2684
+ <circle cx="12" cy="18" r="0.8" fill={accentColor} />
2685
+ {/* nose */}
2686
+ <path d="M 9.5 19.5 L 10 20 L 10.5 19.5" stroke={accentColor} strokeWidth="0.8" fill="none" strokeLinecap="round" />
2687
+ {/* whiskers */}
2688
+ <line x1="5" y1="19" x2="2" y2="18" stroke={color} strokeWidth="0.6" />
2689
+ <line x1="5" y1="20" x2="2" y2="20" stroke={color} strokeWidth="0.6" />
2690
+ <line x1="15" y1="19" x2="18" y2="18" stroke={color} strokeWidth="0.6" />
2691
+ <line x1="15" y1="20" x2="18" y2="20" stroke={color} strokeWidth="0.6" />
2692
+ {/* body — oval */}
2693
+ <ellipse cx="18" cy="26" rx="8" ry="5" stroke={color} strokeWidth="1.5" fill="none" />
2694
+ {/* tail */}
2695
+ <g style={{ transformOrigin: '26px 26px', animation: tailAnim }}>
2696
+ <path d="M 26 26 Q 30 22 28 18" {...strokeProps} />
2697
+ </g>
2698
+ {/* legs */}
2699
+ <line x1="13" y1="30" x2="13" y2="36" {...strokeProps} />
2700
+ <line x1="23" y1="30" x2="23" y2="36" {...strokeProps} />
2701
+ <line x1="16" y1="31" x2="16" y2="36" {...strokeProps} />
2702
+ <line x1="20" y1="31" x2="20" y2="36" {...strokeProps} />
2703
+ </>
2704
+ );
2705
+
2706
+ if (pose === 'sleep') {
2707
+ return (
2708
+ <svg width="32" height="40" viewBox="0 0 32 40">
2709
+ {/* curled up cat — circle with tail */}
2710
+ <circle cx="16" cy="30" r="8" stroke={color} strokeWidth="1.5" fill="none" />
2711
+ <circle cx="10" cy="28" r="3" stroke={color} strokeWidth="1.5" fill="none" />
2712
+ <line x1="9" y1="27" x2="9" y2="29" stroke={color} strokeWidth="0.8" />
2713
+ <line x1="11" y1="27" x2="11" y2="29" stroke={color} strokeWidth="0.8" />
2714
+ <path d="M 23 32 Q 28 32 26 26" {...strokeProps} />
2715
+ {/* zzz */}
2716
+ <text x="20" y="20" fill={accentColor} fontSize="6" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
2717
+ <text x="24" y="14" fill={accentColor} fontSize="4" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
2718
+ </svg>
2719
+ );
2720
+ }
2721
+
2722
+ if (pose === 'fail') {
2723
+ return (
2724
+ <svg width="32" height="40" viewBox="0 0 32 40">
2725
+ {/* belly up */}
2726
+ <ellipse cx="18" cy="26" rx="8" ry="5" stroke={color} strokeWidth="1.5" fill="none" />
2727
+ <circle cx="10" cy="24" r="4" stroke={color} strokeWidth="1.5" fill="none" />
2728
+ <line x1="8" y1="23" x2="9" y2="24" stroke={accentColor} strokeWidth="0.8" />
2729
+ <line x1="9" y1="23" x2="8" y2="24" stroke={accentColor} strokeWidth="0.8" />
2730
+ <line x1="11" y1="23" x2="12" y2="24" stroke={accentColor} strokeWidth="0.8" />
2731
+ <line x1="12" y1="23" x2="11" y2="24" stroke={accentColor} strokeWidth="0.8" />
2732
+ {/* legs up */}
2733
+ <line x1="14" y1="22" x2="14" y2="16" {...strokeProps} />
2734
+ <line x1="18" y1="22" x2="18" y2="15" {...strokeProps} />
2735
+ <line x1="22" y1="22" x2="22" y2="16" {...strokeProps} />
2736
+ </svg>
2737
+ );
2738
+ }
2739
+
2740
+ if (pose === 'done') {
2741
+ return (
2742
+ <svg width="32" height="40" viewBox="0 0 32 40">
2743
+ {/* jumping — body elevated */}
2744
+ <g style={{ transform: 'translateY(-2px)' }}>
2745
+ {body('none')}
2746
+ </g>
2747
+ <text x="2" y="8" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
2748
+ <text x="26" y="10" fill="#ffd700" fontSize="8" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
2749
+ </svg>
2750
+ );
2751
+ }
2752
+
2753
+ if (pose === 'work') {
2754
+ return (
2755
+ <svg width="32" height="40" viewBox="0 0 32 40">
2756
+ {body('stick-arm-hammer 0.4s ease-in-out infinite')}
2757
+ </svg>
2758
+ );
2759
+ }
2760
+
2761
+ if (pose === 'wake') {
2762
+ return (
2763
+ <svg width="32" height="40" viewBox="0 0 32 40">
2764
+ {/* stretching — elongated body */}
2765
+ <circle cx="8" cy="22" r="4" stroke={color} strokeWidth="1.5" fill="none" />
2766
+ <path d="M 4 19 L 5 16 L 8 18 Z" fill={color} />
2767
+ <path d="M 12 19 L 11 16 L 8 18 Z" fill={color} />
2768
+ <circle cx="6.5" cy="22" r="0.6" fill={accentColor} />
2769
+ <circle cx="9.5" cy="22" r="0.6" fill={accentColor} />
2770
+ <ellipse cx="20" cy="28" rx="10" ry="4" stroke={color} strokeWidth="1.5" fill="none" />
2771
+ <line x1="14" y1="32" x2="14" y2="38" {...strokeProps} />
2772
+ <line x1="26" y1="32" x2="26" y2="38" {...strokeProps} />
2773
+ <path d="M 30 28 Q 32 24 30 20" {...strokeProps} />
2774
+ </svg>
2775
+ );
2776
+ }
2777
+
2778
+ // idle — tail swaying
2779
+ return (
2780
+ <svg width="32" height="40" viewBox="0 0 32 40">
2781
+ {body('stick-arm-wave 2s ease-in-out infinite')}
2782
+ </svg>
2783
+ );
2784
+ }
2785
+
2786
+ function StickDog({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
2787
+ // Side-profile dog — elongated snout forward, triangular perked ear, visible tail
2788
+ // Designed to read clearly at small sizes with distinct dog silhouette
2789
+
2790
+ if (pose === 'sleep') {
2791
+ return (
2792
+ <svg width="40" height="40" viewBox="0 0 40 40">
2793
+ {/* body lying down */}
2794
+ <ellipse cx="22" cy="32" rx="12" ry="4" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.15" />
2795
+ {/* head resting on paws — side profile */}
2796
+ <path d="M 10 32 Q 6 30 4 32 Q 2 33 3 35 L 10 35 Z" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.2" strokeLinejoin="round" />
2797
+ {/* long snout */}
2798
+ <path d="M 3 34 L 1 35 L 3 36" stroke={color} strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
2799
+ {/* floppy ear */}
2800
+ <path d="M 8 30 Q 6 33 9 34" stroke={color} strokeWidth="2" fill={color} fillOpacity="0.35" strokeLinecap="round" />
2801
+ {/* closed eye */}
2802
+ <path d="M 6 33 Q 7 32.5 8 33" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
2803
+ {/* nose */}
2804
+ <ellipse cx="1.5" cy="35" rx="0.9" ry="0.7" fill={color} />
2805
+ {/* curled tail */}
2806
+ <path d="M 33 32 Q 38 30 36 26 Q 35 25 36 24" stroke={color} strokeWidth="2" fill="none" strokeLinecap="round" />
2807
+ <text x="18" y="18" fill={accentColor} fontSize="7" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
2808
+ <text x="24" y="12" fill={accentColor} fontSize="5" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
2809
+ </svg>
2810
+ );
2811
+ }
2812
+
2813
+ if (pose === 'fail') {
2814
+ return (
2815
+ <svg width="40" height="40" viewBox="0 0 40 40">
2816
+ {/* belly up */}
2817
+ <ellipse cx="22" cy="32" rx="12" ry="4" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.15" />
2818
+ {/* head upside down */}
2819
+ <path d="M 10 32 Q 6 34 4 32 Q 2 31 3 29 L 10 29 Z" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.2" strokeLinejoin="round" />
2820
+ <path d="M 3 30 L 1 29 L 3 28" stroke={color} strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
2821
+ {/* X eyes */}
2822
+ <line x1="5" y1="30" x2="6.5" y2="31.5" stroke={accentColor} strokeWidth="1.2" strokeLinecap="round" />
2823
+ <line x1="6.5" y1="30" x2="5" y2="31.5" stroke={accentColor} strokeWidth="1.2" strokeLinecap="round" />
2824
+ {/* tongue hanging out sideways */}
2825
+ <path d="M 2 30 Q 1 27 2 25" stroke="#ff6b9d" strokeWidth="1.5" fill="none" strokeLinecap="round" />
2826
+ {/* all 4 legs sticking up */}
2827
+ <line x1="14" y1="28" x2="13" y2="20" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
2828
+ <line x1="18" y1="28" x2="18" y2="18" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
2829
+ <line x1="26" y1="28" x2="26" y2="18" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
2830
+ <line x1="30" y1="28" x2="31" y2="20" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
2831
+ {/* limp tail */}
2832
+ <path d="M 33 32 L 37 34" stroke={color} strokeWidth="2" fill="none" strokeLinecap="round" />
2833
+ </svg>
2834
+ );
2835
+ }
2836
+
2837
+ // Standing side-profile dog
2838
+ const standingDog = (tailAnim: string, bounce: string = '') => (
2839
+ <g style={bounce ? { transform: bounce } : {}}>
2840
+ {/* body — side profile, clearly elongated horizontal */}
2841
+ <path d="M 11 22 L 28 22 Q 32 22 32 26 L 32 30 Q 32 32 30 32 L 9 32 Q 7 32 7 30 L 7 27 Q 7 23 11 22 Z"
2842
+ stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.15" strokeLinejoin="round" />
2843
+ {/* head — side profile with long snout pointing LEFT */}
2844
+ <path d="M 11 22 Q 8 22 6 20 Q 4 20 2 22 Q 0 23 1 25 Q 1 27 3 27 L 8 27 Q 10 27 11 25 Z"
2845
+ stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.2" strokeLinejoin="round" />
2846
+ {/* triangular perked ear (pointing up-back) */}
2847
+ <path d="M 8 22 L 10 15 L 12 20 Z" stroke={color} strokeWidth="1.5" fill={color} fillOpacity="0.4" strokeLinejoin="round" />
2848
+ {/* big black nose at tip */}
2849
+ <ellipse cx="1" cy="24" rx="1.3" ry="1" fill={color} />
2850
+ {/* eye */}
2851
+ <circle cx="7" cy="23" r="1" fill={accentColor} />
2852
+ <circle cx="6.7" cy="22.7" r="0.3" fill="#fff" />
2853
+ {/* mouth line */}
2854
+ <path d="M 1 25.5 Q 3 27 6 26" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
2855
+ {/* tongue hanging out */}
2856
+ <path d="M 2.5 26.5 Q 3 28.5 2 29" stroke="#ff6b9d" strokeWidth="1.2" fill="#ff6b9d" strokeLinecap="round" />
2857
+ {/* curled tail (pointing up-right) — wags */}
2858
+ <g style={{ transformOrigin: '32px 26px', animation: tailAnim }}>
2859
+ <path d="M 32 26 Q 37 24 36 19 Q 36 17 38 17" stroke={color} strokeWidth="2.2" fill="none" strokeLinecap="round" />
2860
+ </g>
2861
+ {/* 4 legs — visible in side profile (2 front + 2 back, front pair visible) */}
2862
+ <line x1="10" y1="32" x2="10" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
2863
+ <line x1="14" y1="32" x2="14" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
2864
+ <line x1="26" y1="32" x2="26" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
2865
+ <line x1="30" y1="32" x2="30" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
2866
+ {/* paws */}
2867
+ <rect x="8.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
2868
+ <rect x="12.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
2869
+ <rect x="24.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
2870
+ <rect x="28.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
2871
+ {/* collar */}
2872
+ <line x1="9" y1="27" x2="12" y2="27" stroke={accentColor} strokeWidth="1.5" strokeLinecap="round" />
2873
+ <circle cx="10.5" cy="28" r="0.9" fill={accentColor} />
2874
+ </g>
2875
+ );
2876
+
2877
+ if (pose === 'done') {
2878
+ return (
2879
+ <svg width="40" height="40" viewBox="0 0 40 40">
2880
+ {standingDog('stick-arm-wave 0.3s ease-in-out infinite', 'translateY(-3px)')}
2881
+ <text x="2" y="10" fill="#ffd700" fontSize="7" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
2882
+ <text x="34" y="12" fill="#ffd700" fontSize="9" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
2883
+ <text x="18" y="6" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.5s' }}>✦</text>
2884
+ </svg>
2885
+ );
2886
+ }
2887
+
2888
+ if (pose === 'work') {
2889
+ return (
2890
+ <svg width="40" height="40" viewBox="0 0 40 40">
2891
+ {standingDog('stick-arm-wave 0.25s ease-in-out infinite')}
2892
+ {/* bone in mouth */}
2893
+ <g transform="translate(-6, 0)">
2894
+ <circle cx="0" cy="25" r="1.5" fill={accentColor} stroke={color} strokeWidth="0.6" />
2895
+ <rect x="0" y="24.2" width="5" height="1.6" fill={accentColor} stroke={color} strokeWidth="0.6" rx="0.4" />
2896
+ <circle cx="5" cy="25" r="1.5" fill={accentColor} stroke={color} strokeWidth="0.6" />
2897
+ </g>
2898
+ </svg>
2899
+ );
2900
+ }
2901
+
2902
+ if (pose === 'wake') {
2903
+ return (
2904
+ <svg width="40" height="40" viewBox="0 0 40 40">
2905
+ {standingDog('stick-arm-wave 1.5s ease-in-out infinite')}
2906
+ {/* yawn — open mouth replaces tongue */}
2907
+ <ellipse cx="2" cy="26" rx="1.3" ry="1.8" fill={color} opacity="0.5" />
2908
+ </svg>
2909
+ );
2910
+ }
2911
+
2912
+ // idle — standing, happy tail wag
2913
+ return (
2914
+ <svg width="40" height="40" viewBox="0 0 40 40">
2915
+ {standingDog('stick-arm-wave 0.6s ease-in-out infinite')}
2916
+ </svg>
2917
+ );
2918
+ }
2919
+
2920
+ function StickPig({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
2921
+ const pink = '#ff9ecb';
2922
+ const pinkFill = '#ff9ecb';
2923
+
2924
+ if (pose === 'sleep') {
2925
+ return (
2926
+ <svg width="32" height="40" viewBox="0 0 32 40">
2927
+ <ellipse cx="16" cy="30" rx="12" ry="7" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.25" />
2928
+ <circle cx="8" cy="28" r="4" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.3" />
2929
+ {/* pig snout disc */}
2930
+ <ellipse cx="4.5" cy="29" rx="2.5" ry="1.8" stroke={pink} strokeWidth="1.5" fill={pinkFill} fillOpacity="0.4" />
2931
+ <circle cx="4" cy="29" r="0.5" fill={color} />
2932
+ <circle cx="5" cy="29" r="0.5" fill={color} />
2933
+ {/* pointy triangular ears */}
2934
+ <path d="M 5 24 L 6 22 L 8 25 Z" fill={pinkFill} stroke={pink} strokeWidth="1" />
2935
+ <path d="M 9 24 L 10 22 L 11 25 Z" fill={pinkFill} stroke={pink} strokeWidth="1" />
2936
+ {/* closed eyes */}
2937
+ <path d="M 7 27 Q 7.5 26.5 8 27" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
2938
+ <path d="M 9 27 Q 9.5 26.5 10 27" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
2939
+ {/* curly tail */}
2940
+ <path d="M 28 28 Q 30 26 28 24 Q 26 24 28 22" stroke={pink} strokeWidth="2" fill="none" strokeLinecap="round" />
2941
+ <text x="16" y="16" fill={accentColor} fontSize="7" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
2942
+ <text x="21" y="10" fill={accentColor} fontSize="5" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
2943
+ </svg>
2944
+ );
2945
+ }
2946
+
2947
+ if (pose === 'fail') {
2948
+ return (
2949
+ <svg width="32" height="40" viewBox="0 0 32 40">
2950
+ <ellipse cx="18" cy="29" rx="11" ry="5" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.25" />
2951
+ <circle cx="8" cy="27" r="4.5" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.3" />
2952
+ <ellipse cx="7" cy="23" rx="2.5" ry="2" stroke={pink} strokeWidth="1.5" fill={pinkFill} fillOpacity="0.5" />
2953
+ <circle cx="6.3" cy="23" r="0.5" fill={color} />
2954
+ <circle cx="7.7" cy="23" r="0.5" fill={color} />
2955
+ <path d="M 4 28 Q 0 30 2 34" fill={pinkFill} stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
2956
+ <path d="M 12 28 Q 16 30 14 34" fill={pinkFill} stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
2957
+ <line x1="6" y1="26" x2="7.5" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
2958
+ <line x1="7.5" y1="26" x2="6" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
2959
+ <line x1="9" y1="26" x2="10.5" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
2960
+ <line x1="10.5" y1="26" x2="9" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
2961
+ <line x1="14" y1="25" x2="13" y2="17" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
2962
+ <line x1="18" y1="25" x2="18" y2="16" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
2963
+ <line x1="22" y1="25" x2="22" y2="17" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
2964
+ <line x1="26" y1="25" x2="27" y2="18" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
2965
+ </svg>
2966
+ );
2967
+ }
2968
+
2969
+ const pigBody = (tailAnim: string, bounce: string = '') => (
2970
+ <g style={bounce ? { transform: bounce } : {}}>
2971
+ {/* round pig body */}
2972
+ <ellipse cx="18" cy="27" rx="11" ry="6.5" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.25" />
2973
+ {/* round head */}
2974
+ <circle cx="9" cy="18" r="6" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.3" />
2975
+ {/* pig snout — flat disc with nostrils */}
2976
+ <ellipse cx="5" cy="20" rx="3" ry="2.2" stroke={pink} strokeWidth="1.5" fill={pinkFill} fillOpacity="0.5" />
2977
+ <circle cx="4" cy="20" r="0.6" fill={color} />
2978
+ <circle cx="6" cy="20" r="0.6" fill={color} />
2979
+ {/* triangular pointed ears */}
2980
+ <path d="M 6 13 L 7 10 L 9 14 Z" fill={pinkFill} stroke={pink} strokeWidth="1.2" strokeLinejoin="round" />
2981
+ <path d="M 11 13 L 12 10 L 14 14 Z" fill={pinkFill} stroke={pink} strokeWidth="1.2" strokeLinejoin="round" />
2982
+ {/* eyes */}
2983
+ <circle cx="8" cy="17" r="1" fill={color} />
2984
+ <circle cx="12" cy="17" r="1" fill={color} />
2985
+ <circle cx="7.7" cy="16.7" r="0.3" fill="#fff" />
2986
+ <circle cx="11.7" cy="16.7" r="0.3" fill="#fff" />
2987
+ {/* smile */}
2988
+ <path d="M 7 22 Q 9 23 11 22" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
2989
+ {/* curly tail — wagging */}
2990
+ <g style={{ transformOrigin: '29px 25px', animation: tailAnim }}>
2991
+ <path d="M 29 25 Q 32 23 30 21 Q 28 21 30 19 Q 31 18 32 19" stroke={pink} strokeWidth="2" fill="none" strokeLinecap="round" />
2992
+ </g>
2993
+ {/* trotter legs */}
2994
+ <line x1="12" y1="32" x2="12" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
2995
+ <line x1="16" y1="33" x2="16" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
2996
+ <line x1="20" y1="33" x2="20" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
2997
+ <line x1="24" y1="32" x2="24" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
2998
+ {/* hooves */}
2999
+ <rect x="10.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
3000
+ <rect x="14.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
3001
+ <rect x="18.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
3002
+ <rect x="22.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
3003
+ </g>
3004
+ );
3005
+
3006
+ if (pose === 'done') {
3007
+ return (
3008
+ <svg width="32" height="40" viewBox="0 0 32 40">
3009
+ {pigBody('stick-arm-wave 0.4s ease-in-out infinite', 'translateY(-2px)')}
3010
+ <text x="2" y="8" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
3011
+ <text x="26" y="10" fill="#ffd700" fontSize="8" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
3012
+ </svg>
3013
+ );
3014
+ }
3015
+
3016
+ if (pose === 'work') {
3017
+ return (
3018
+ <svg width="32" height="40" viewBox="0 0 32 40">
3019
+ {pigBody('stick-arm-wave 0.3s ease-in-out infinite')}
3020
+ </svg>
3021
+ );
3022
+ }
3023
+
3024
+ if (pose === 'wake') {
3025
+ return (
3026
+ <svg width="32" height="40" viewBox="0 0 32 40">
3027
+ {pigBody('stick-arm-wave 1.5s ease-in-out infinite')}
3028
+ <ellipse cx="9" cy="20" rx="1.3" ry="1.5" fill={color} opacity="0.4" />
3029
+ </svg>
3030
+ );
3031
+ }
3032
+
3033
+ return (
3034
+ <svg width="32" height="40" viewBox="0 0 32 40">
3035
+ {pigBody('stick-arm-wave 0.7s ease-in-out infinite')}
3036
+ </svg>
3037
+ );
3038
+ }
3039
+
3040
+ function EmojiMascot({ pose, seed }: { pose: MascotPose; seed: number }) {
3041
+ const characters = ['🦊', '🐱', '🐼', '🦉', '🐸', '🦝', '🐙', '🦖', '🐰', '🦄', '🐺', '🧙‍♂️', '🧝‍♀️', '🦸‍♂️', '🥷', '🐲'];
3042
+ const character = characters[seed % characters.length];
3043
+ let display = character;
3044
+ if (pose === 'sleep') display = ['😴', '💤', '🌙', '💤'][Math.floor(Date.now() / 1200) % 4];
3045
+ else if (pose === 'work') { const tools = ['🔨', '⚙️', '🛠️', '⚡']; const tick = Math.floor(Date.now() / 400); display = tick % 3 === 0 ? character : tools[tick % tools.length]; }
3046
+ else if (pose === 'done') display = ['🎉', '🎊', '🥳', '🌟'][Math.floor(Date.now() / 600) % 4];
3047
+ else if (pose === 'fail') display = ['😵', '💫', '🤕', '😿'][seed % 4];
3048
+ else if (pose === 'wake') display = ['🥱', '☕', '🌅'][Math.floor(Date.now() / 1000) % 3];
3049
+ return <div style={{ fontSize: '24px', lineHeight: 1 }}>{display}</div>;
3050
+ }
3051
+
3052
+ function StickFigure({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
3053
+ // viewBox 32×40: head at (16,8), body (16,12)→(16,26), arms from (16,14), legs from (16,26)
3054
+ const strokeProps = { stroke: color, strokeWidth: 2, strokeLinecap: 'round' as const, fill: 'none' };
3055
+
3056
+ if (pose === 'sleep') {
3057
+ // Lying down, sleeping
3058
+ return (
3059
+ <svg width="32" height="40" viewBox="0 0 32 40">
3060
+ {/* body horizontal */}
3061
+ <circle cx="8" cy="30" r="3" {...strokeProps} fill={color} />
3062
+ <line x1="11" y1="30" x2="26" y2="30" {...strokeProps} />
3063
+ <line x1="14" y1="30" x2="18" y2="26" {...strokeProps} />
3064
+ <line x1="20" y1="30" x2="24" y2="34" {...strokeProps} />
3065
+ {/* zzz */}
3066
+ <text x="18" y="14" fill={accentColor} fontSize="8" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
3067
+ <text x="22" y="10" fill={accentColor} fontSize="6" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
3068
+ </svg>
3069
+ );
3070
+ }
3071
+
3072
+ if (pose === 'wake') {
3073
+ // Stretching — arms up
3074
+ return (
3075
+ <svg width="32" height="40" viewBox="0 0 32 40">
3076
+ <circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
3077
+ <line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
3078
+ <line x1="16" y1="14" x2="10" y2="6" {...strokeProps} />
3079
+ <line x1="16" y1="14" x2="22" y2="6" {...strokeProps} />
3080
+ <line x1="16" y1="26" x2="12" y2="34" {...strokeProps} />
3081
+ <line x1="16" y1="26" x2="20" y2="34" {...strokeProps} />
3082
+ {/* ☼ */}
3083
+ <circle cx="26" cy="6" r="2" fill={accentColor} opacity="0.8" />
3084
+ </svg>
3085
+ );
3086
+ }
3087
+
3088
+ if (pose === 'done') {
3089
+ // Victory pose — both arms up, legs apart
3090
+ return (
3091
+ <svg width="32" height="40" viewBox="0 0 32 40">
3092
+ <circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
3093
+ {/* smile */}
3094
+ <path d="M 14 8 Q 16 10 18 8" stroke={accentColor} strokeWidth="1" fill="none" strokeLinecap="round" />
3095
+ <line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
3096
+ <line x1="16" y1="14" x2="8" y2="4" {...strokeProps} />
3097
+ <line x1="16" y1="14" x2="24" y2="4" {...strokeProps} />
3098
+ <line x1="16" y1="26" x2="10" y2="36" {...strokeProps} />
3099
+ <line x1="16" y1="26" x2="22" y2="36" {...strokeProps} />
3100
+ {/* sparkles */}
3101
+ <text x="4" y="4" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
3102
+ <text x="26" y="6" fill="#ffd700" fontSize="8" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
3103
+ <text x="2" y="20" fill="#ffd700" fontSize="5" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.5s' }}>✦</text>
3104
+ </svg>
3105
+ );
3106
+ }
3107
+
3108
+ if (pose === 'fail') {
3109
+ // Fallen down — lying on back, X eyes (handled via external rotate)
3110
+ return (
3111
+ <svg width="32" height="40" viewBox="0 0 32 40">
3112
+ <circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
3113
+ {/* X eyes */}
3114
+ <line x1="14" y1="6" x2="15" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
3115
+ <line x1="15" y1="6" x2="14" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
3116
+ <line x1="17" y1="6" x2="18" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
3117
+ <line x1="18" y1="6" x2="17" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
3118
+ <line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
3119
+ <line x1="16" y1="14" x2="8" y2="18" {...strokeProps} />
3120
+ <line x1="16" y1="14" x2="24" y2="18" {...strokeProps} />
3121
+ <line x1="16" y1="26" x2="10" y2="34" {...strokeProps} />
3122
+ <line x1="16" y1="26" x2="22" y2="34" {...strokeProps} />
3123
+ </svg>
3124
+ );
3125
+ }
3126
+
3127
+ if (pose === 'work') {
3128
+ // Hammering — left arm stable, right arm swinging with hammer
3129
+ return (
3130
+ <svg width="32" height="40" viewBox="0 0 32 40">
3131
+ <circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
3132
+ <line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
3133
+ {/* left arm holding nail */}
3134
+ <line x1="16" y1="14" x2="10" y2="20" {...strokeProps} />
3135
+ {/* right arm swinging hammer */}
3136
+ <g style={{ transformOrigin: '16px 14px', animation: 'stick-arm-hammer 0.5s ease-in-out infinite' }}>
3137
+ <line x1="16" y1="14" x2="24" y2="14" {...strokeProps} />
3138
+ {/* hammer */}
3139
+ <rect x="24" y="11" width="5" height="6" fill={accentColor} stroke={color} strokeWidth="1" rx="1" />
3140
+ </g>
3141
+ {/* legs walking */}
3142
+ <g style={{ transformOrigin: '16px 26px', animation: 'stick-leg-walk-l 0.5s ease-in-out infinite' }}>
3143
+ <line x1="16" y1="26" x2="12" y2="36" {...strokeProps} />
3144
+ </g>
3145
+ <g style={{ transformOrigin: '16px 26px', animation: 'stick-leg-walk-r 0.5s ease-in-out infinite' }}>
3146
+ <line x1="16" y1="26" x2="20" y2="36" {...strokeProps} />
3147
+ </g>
3148
+ {/* sparks from hammer */}
3149
+ <text x="26" y="22" fill="#ff9500" fontSize="6" style={{ animation: 'stick-spark 0.5s ease-in-out infinite' }}>✦</text>
3150
+ </svg>
3151
+ );
3152
+ }
3153
+
3154
+ // idle — standing, waving
3155
+ return (
3156
+ <svg width="32" height="40" viewBox="0 0 32 40">
3157
+ <circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
3158
+ {/* eyes dots */}
3159
+ <circle cx="15" cy="7" r="0.6" fill={accentColor} />
3160
+ <circle cx="17" cy="7" r="0.6" fill={accentColor} />
3161
+ <line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
3162
+ {/* left arm down */}
3163
+ <line x1="16" y1="14" x2="12" y2="22" {...strokeProps} />
3164
+ {/* right arm waving */}
3165
+ <g style={{ transformOrigin: '16px 14px', animation: 'stick-arm-wave 2s ease-in-out infinite' }}>
3166
+ <line x1="16" y1="14" x2="22" y2="14" {...strokeProps} />
3167
+ </g>
3168
+ <line x1="16" y1="26" x2="12" y2="36" {...strokeProps} />
3169
+ <line x1="16" y1="26" x2="20" y2="36" {...strokeProps} />
3170
+ </svg>
3171
+ );
3172
+ }
3173
+
3174
+ function WorkerMascot({ taskStatus, smithStatus, seed, accentColor, theme }: { taskStatus: string; smithStatus: string; seed: number; accentColor: string; theme: MascotTheme }) {
3175
+ if (theme === 'off') return null;
3176
+
3177
+ let pose: MascotPose = 'idle';
3178
+ let animation = 'mascot-idle 3s ease-in-out infinite';
3179
+ let title = 'Ready for work';
3180
+ const color = '#e6edf3';
3181
+
3182
+ if (smithStatus === 'down') {
3183
+ pose = 'sleep';
3184
+ animation = 'mascot-sleep 2.5s ease-in-out infinite';
3185
+ title = 'Smith is down — sleeping';
3186
+ } else if (taskStatus === 'running') {
3187
+ pose = 'work';
3188
+ animation = 'mascot-work 0.6s ease-in-out infinite';
3189
+ title = 'Hard at work!';
3190
+ } else if (taskStatus === 'done') {
3191
+ pose = 'done';
3192
+ // Celebrate 2 times (~2.4s total), then hold the pose quietly
3193
+ animation = 'mascot-celebrate 2.4s ease-in-out forwards';
3194
+ title = 'Task done!';
3195
+ } else if (taskStatus === 'failed') {
3196
+ pose = 'fail';
3197
+ animation = 'mascot-fall 0.8s ease-out forwards';
3198
+ title = 'Task failed';
3199
+ } else if (smithStatus === 'starting') {
3200
+ pose = 'wake';
3201
+ animation = 'mascot-sleep 1.8s ease-in-out infinite';
3202
+ title = 'Waking up...';
3203
+ } else {
3204
+ animation = 'mascot-idle 3s ease-in-out infinite';
3205
+ title = 'Ready for work';
3206
+ }
3207
+
3208
+ let figure: React.ReactNode;
3209
+ if (theme === 'stick') figure = <StickFigure pose={pose} color={color} accentColor={accentColor} />;
3210
+ else if (theme === 'cat') figure = <StickCat pose={pose} color={color} accentColor={accentColor} />;
3211
+ else if (theme === 'dog') figure = <StickDog pose={pose} color={color} accentColor={accentColor} />;
3212
+ else if (theme === 'pig') figure = <StickPig pose={pose} color={color} accentColor={accentColor} />;
3213
+ else figure = <EmojiMascot pose={pose} seed={seed} />;
3214
+
3215
+ return (
3216
+ <div
3217
+ className="absolute pointer-events-none select-none"
3218
+ style={{
3219
+ top: '-36px',
3220
+ right: '-8px',
3221
+ animation,
3222
+ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.6))',
3223
+ zIndex: 10,
3224
+ transformOrigin: 'bottom center',
3225
+ }}
3226
+ title={title}
3227
+ >
3228
+ {figure}
3229
+ </div>
3230
+ );
3231
+ }
3232
+
2515
3233
  function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2516
- const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, inboxPending = 0, inboxFailed = 0 } = data;
3234
+ const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, onSaveAsTemplate, mascotTheme, inboxPending = 0, inboxFailed = 0 } = data;
2517
3235
  const c = COLORS[colorIdx % COLORS.length];
2518
3236
  const smithStatus = state?.smithStatus || 'down';
2519
3237
  const taskStatus = state?.taskStatus || 'idle';
@@ -2524,10 +3242,15 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2524
3242
  const step = currentStep !== undefined ? config.steps[currentStep] : undefined;
2525
3243
  const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active';
2526
3244
 
3245
+ // Stable seed for mascot character from agent id
3246
+ const mascotSeed = config.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
3247
+
2527
3248
  return (
2528
- <div className="w-52 flex flex-col rounded-lg select-none"
3249
+ <div className="w-52 flex flex-col rounded-lg select-none relative"
2529
3250
  style={{ border: `1px solid ${c.border}${taskStatus === 'running' ? '90' : '40'}`, background: c.bg,
2530
3251
  boxShadow: taskInfo.glow ? `0 0 12px ${taskInfo.color}25` : smithInfo.glow ? `0 0 8px ${smithInfo.color}15` : 'none' }}>
3252
+ <style>{MASCOT_STYLES}</style>
3253
+ <WorkerMascot taskStatus={taskStatus} smithStatus={smithStatus} seed={mascotSeed} accentColor={c.accent} theme={mascotTheme} />
2531
3254
  <Handle type="target" position={Position.Left} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
2532
3255
  <Handle type="source" position={Position.Right} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
2533
3256
 
@@ -2649,6 +3372,8 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2649
3372
  className="text-[9px] text-gray-600 hover:text-purple-400 px-1" title="Memory">🧠</button>
2650
3373
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowLog(); }}
2651
3374
  className="text-[9px] text-gray-600 hover:text-gray-300 px-1" title="Logs">📋</button>
3375
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSaveAsTemplate(); }}
3376
+ className="text-[9px] text-gray-600 hover:text-yellow-400 px-1" title="Save as template">💾</button>
2652
3377
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onEdit(); }}
2653
3378
  className="text-[9px] text-gray-600 hover:text-blue-400 px-1">✏️</button>
2654
3379
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onRemove(); }}
@@ -2682,6 +3407,14 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2682
3407
  const [memoryTarget, setMemoryTarget] = useState<{ id: string; label: string } | null>(null);
2683
3408
  const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
2684
3409
  const [showBusPanel, setShowBusPanel] = useState(false);
3410
+ const [mascotTheme, setMascotTheme] = useState<MascotTheme>(() => {
3411
+ if (typeof window === 'undefined') return 'off';
3412
+ return (localStorage.getItem('forge.mascotTheme') as MascotTheme) || 'off';
3413
+ });
3414
+ const updateMascotTheme = (t: MascotTheme) => {
3415
+ setMascotTheme(t);
3416
+ if (typeof window !== 'undefined') localStorage.setItem('forge.mascotTheme', t);
3417
+ };
2685
3418
  const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string>; isPrimary?: boolean; skipPermissions?: boolean; persistentSession?: boolean; boundSessionId?: string; initialPos?: { x: number; y: number } }[]>([]);
2686
3419
  const [termPicker, setTermPicker] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; supportsSession?: boolean; currentSessionId: string | null; initialPos?: { x: number; y: number } } | null>(null);
2687
3420
 
@@ -2769,6 +3502,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2769
3502
  },
2770
3503
  onPause: () => wsApi(workspaceId!, 'pause', { agentId: agent.id }),
2771
3504
  onStop: () => wsApi(workspaceId!, 'stop', { agentId: agent.id }),
3505
+ mascotTheme,
2772
3506
  onMarkIdle: () => wsApi(workspaceId!, 'mark_done', { agentId: agent.id, notify: false }),
2773
3507
  onMarkDone: (notify: boolean) => wsApi(workspaceId!, 'mark_done', { agentId: agent.id, notify }),
2774
3508
  onMarkFailed: (notify: boolean) => wsApi(workspaceId!, 'mark_failed', { agentId: agent.id, notify }),
@@ -2804,6 +3538,20 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2804
3538
  const currentSessionId = resolveRes?.currentSessionId ?? null;
2805
3539
  setTermPicker({ agent, sessName, workDir, supportsSession: resolveRes?.supportsSession ?? true, currentSessionId, initialPos });
2806
3540
  },
3541
+ onSaveAsTemplate: async () => {
3542
+ const name = prompt('Template name:', agent.label);
3543
+ if (!name) return;
3544
+ const desc = prompt('Description (optional):', '');
3545
+ try {
3546
+ await fetch('/api/smith-templates', {
3547
+ method: 'POST',
3548
+ headers: { 'Content-Type': 'application/json' },
3549
+ body: JSON.stringify({ config: agent, name, icon: agent.icon, description: desc || '' }),
3550
+ });
3551
+ } catch {
3552
+ alert('Failed to save template');
3553
+ }
3554
+ },
2807
3555
  onSwitchSession: async () => {
2808
3556
  if (!workspaceId) return;
2809
3557
  setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
@@ -2822,7 +3570,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2822
3570
  };
2823
3571
  });
2824
3572
  });
2825
- }, [agents, states, logPreview, workspaceId]); // eslint-disable-line react-hooks/exhaustive-deps
3573
+ }, [agents, states, logPreview, workspaceId, mascotTheme]); // eslint-disable-line react-hooks/exhaustive-deps
2826
3574
 
2827
3575
  // Derive edges from dependsOn
2828
3576
  const rfEdges = useMemo(() => {
@@ -2994,6 +3742,16 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2994
3742
  </>
2995
3743
  )}
2996
3744
  <div className="ml-auto flex items-center gap-2">
3745
+ <select value={mascotTheme} onChange={e => updateMascotTheme(e.target.value as MascotTheme)}
3746
+ className="text-[8px] px-1.5 py-0.5 rounded border border-[#30363d] bg-[#0d1117] text-gray-500 hover:text-white hover:border-[#58a6ff]/60 cursor-pointer focus:outline-none"
3747
+ title="Mascot theme">
3748
+ <option value="stick">🏃 Stick</option>
3749
+ <option value="cat">🐱 Cat</option>
3750
+ <option value="dog">🐶 Dog</option>
3751
+ <option value="pig">🐷 Pig</option>
3752
+ <option value="emoji">🎭 Emoji</option>
3753
+ <option value="off">⊘ Off</option>
3754
+ </select>
2997
3755
  <button onClick={() => setShowBusPanel(true)}
2998
3756
  className={`text-[8px] px-2 py-0.5 rounded border border-[#30363d] hover:border-[#58a6ff]/60 ${busLog.length > 0 ? 'text-[#58a6ff]' : 'text-gray-500'}`}>
2999
3757
  📡 Logs{busLog.length > 0 ? ` (${busLog.length})` : ''}