jarvis-agent-factory 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jarvis-agent-factory",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Jarvis Agent Factory CLI — 跨平台多智能体 AI 编程助手配置安装器 | Multi-agent AI coding assistant config installer for Claude Code / OpenCode / Codex",
5
5
  "keywords": [
6
6
  "jarvis",
@@ -0,0 +1,197 @@
1
+ <!DOCTYPE html>
2
+ <html lang=zh>
3
+ <head>
4
+ <meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
5
+ <title>Agents · Jarvis Engine</title>
6
+ <style>
7
+ :root{--bg:#0d1117;--card:#161b22;--border:#30363d;--text:#c9d1d9;--muted:#8b949e;--accent:#FF6B35;--green:#3fb950;--blue:#58a6ff;--purple:#bc8cff;--yellow:#d2991d}
8
+ *{box-sizing:border-box;margin:0;padding:0}
9
+ body{font-family:system-ui;background:var(--bg);color:var(--text);min-height:100vh}
10
+ header{background:var(--card);border-bottom:1px solid var(--border);padding:12px 24px;display:flex;justify-content:space-between;align-items:center}
11
+ h1{color:var(--accent);font-size:18px}
12
+ nav{display:flex;gap:8px}
13
+ nav a{color:var(--muted);text-decoration:none;padding:6px 14px;border-radius:6px;font-size:13px;transition:all .2s}
14
+ nav a:hover,nav a.active{color:var(--text);background:var(--border)}
15
+ main{max-width:1200px;margin:0 auto;padding:24px}
16
+ .toolbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;flex-wrap:wrap;gap:12px}
17
+ .toolbar .info{color:var(--muted);font-size:13px}
18
+ .model-filter{display:flex;gap:6px;flex-wrap:wrap}
19
+ .model-filter button{padding:4px 10px;border-radius:12px;border:1px solid var(--border);background:var(--card);color:var(--muted);cursor:pointer;font-size:11px;transition:all .2s}
20
+ .model-filter button:hover,.model-filter button.sel{border-color:var(--accent);color:var(--accent)}
21
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
22
+ /* Pixel card */
23
+ .pixel-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px;cursor:pointer;transition:all .2s;position:relative;overflow:hidden}
24
+ .pixel-card:hover{border-color:var(--accent);transform:translateY(-2px)}
25
+ .pixel-card.custom{border-color:var(--purple)}
26
+ .pixel-card .role-tag{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
27
+ .pixel-card .agent-name{font-size:14px;font-weight:600;margin:4px 0}
28
+ .pixel-card .model-name{font-size:11px;color:var(--accent);margin-top:4px}
29
+ .pixel-card .model-name.custom{color:var(--purple)}
30
+ /* Pixel art avatar — 12x12 CSS grid */
31
+ .pixel-avatar{width:60px;height:60px;margin:8px auto;image-rendering:pixelated;position:relative}
32
+ .pixel-avatar .px{position:absolute;width:5px;height:5px;border-radius:0}
33
+ /* Pixel art patterns per icon */
34
+ .px-brain{background:var(--purple)} .px-layout{background:var(--blue)} .px-palette{background:var(--accent)}
35
+ .px-database{background:var(--green)} .px-test{background:var(--yellow)} .px-server{background:var(--blue)}
36
+ .px-route{background:var(--accent)} .px-cog{background:var(--muted)} .px-table{background:var(--green)}
37
+ .px-globe{background:var(--blue)} .px-play{background:var(--green)} .px-file{background:var(--muted)}
38
+ .px-map{background:var(--purple)} .px-list{background:var(--yellow)} .px-shield{background:var(--accent)}
39
+ .px-eye{background:var(--purple)}
40
+ /* Pixel patterns — simplified 8x8 */
41
+ .avatar-8{display:grid;grid-template-columns:repeat(8,1fr);grid-template-rows:repeat(8,1fr);width:56px;height:56px;gap:1px;margin:8px auto}
42
+ .avatar-8 span{width:100%;height:100%;border-radius:1px}
43
+ /* Model selector popup */
44
+ .modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:99;justify-content:center;align-items:center}
45
+ .modal-overlay.show{display:flex}
46
+ .modal{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;min-width:320px;max-width:400px}
47
+ .modal h3{font-size:16px;margin-bottom:4px}
48
+ .modal .sub{font-size:12px;color:var(--muted);margin-bottom:16px}
49
+ .modal select{width:100%;padding:10px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;font-size:13px;margin-bottom:12px}
50
+ .modal .btns{display:flex;gap:8px;justify-content:flex-end}
51
+ .modal .btn{padding:8px 18px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);cursor:pointer;font-size:13px}
52
+ .modal .btn.save{background:var(--accent);border-color:var(--accent);color:#fff}
53
+ .modal .btn.reset{color:var(--muted)}
54
+ .toast{position:fixed;top:16px;right:16px;background:var(--green);color:#000;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;z-index:999;animation:fadein .3s}
55
+ @keyframes fadein{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
56
+ .empty{text-align:center;padding:40px;color:var(--muted)}
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <header>
61
+ <div style=display:flex;gap:16px;align-items:center>
62
+ <h1>🧠 Jarvis Engine</h1>
63
+ <nav><a href=/dashboard>Pipeline</a><a href=/agents class=active>Agents</a></nav>
64
+ </div>
65
+ <span style=font-size:12px;color:var(--muted) id=agentCount>17 agents</span>
66
+ </header>
67
+ <main>
68
+ <div class=toolbar>
69
+ <div class=info id=toolbarInfo>Click a card to configure model</div>
70
+ <div class=model-filter id=modelFilter></div>
71
+ </div>
72
+ <div class=grid id=agentsGrid><div class=empty>Loading...</div></div>
73
+ </main>
74
+ <div class=modal-overlay id=modal>
75
+ <div class=modal>
76
+ <h3 id=modalName>---</h3>
77
+ <div class=sub id=modalRole>---</div>
78
+ <select id=modalSelect></select>
79
+ <div class=btns>
80
+ <button class="btn reset" onclick=resetModel()>↩ Reset default</button>
81
+ <button class="btn save" onclick=saveModel()>💾 Save</button>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ <div id=toastContainer></div>
86
+
87
+ <script>
88
+ let agents=[],currentAgent=null,currentFilter='all';
89
+
90
+ const PIXEL_PATTERNS = {
91
+ brain: [0,0,0,1,1,0,0,0, 0,1,1,1,1,1,1,0, 1,1,0,1,1,0,1,1, 1,1,0,1,1,0,1,1, 1,1,1,1,1,1,1,1, 0,1,1,1,1,1,1,0, 0,0,1,0,0,1,0,0, 0,0,0,1,1,0,0,0],
92
+ layout:[0,0,0,0,0,0,0,0, 1,1,1,1,1,1,1,1, 1,0,0,0,0,0,0,1, 1,0,1,1,1,0,0,1, 1,0,1,1,1,0,0,1, 1,0,0,0,0,0,0,1, 1,1,1,1,1,1,1,1, 0,0,0,0,0,0,0,0],
93
+ palette:[0,0,1,0,0,1,0,0, 0,1,1,1,1,1,1,0, 1,0,0,0,0,0,0,1, 1,0,1,0,0,1,0,1, 1,0,0,1,1,0,0,1, 1,0,0,0,0,0,0,1, 0,1,1,1,1,1,1,0, 0,0,0,0,0,0,0,0],
94
+ database:[0,0,1,1,1,1,0,0, 0,1,0,0,0,0,1,0, 1,0,0,0,0,0,0,1, 1,0,0,1,1,0,0,1, 1,0,0,1,1,0,0,1, 1,0,0,1,1,0,0,1, 1,0,0,0,0,0,0,1, 1,1,1,1,1,1,1,1],
95
+ test:[0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0, 1,1,0,0,0,0,1,1, 0,0,1,0,0,1,0,0, 0,0,0,1,1,0,0,0, 0,0,1,0,0,1,0,0, 1,1,0,0,0,0,1,1, 0,0,0,0,0,0,0,0],
96
+ server:[0,0,1,1,1,1,0,0, 0,1,0,0,0,0,1,0, 1,0,1,1,1,0,0,1, 1,0,1,0,1,0,0,1, 1,0,1,1,1,0,0,1, 1,0,0,0,0,0,0,1, 1,0,0,0,0,0,0,1, 0,1,1,1,1,1,1,0],
97
+ route:[0,0,0,0,0,0,1,0, 0,0,0,0,0,1,0,0, 0,0,0,0,1,0,1,0, 0,0,0,1,0,0,1,0, 0,0,1,0,0,0,1,0, 0,1,0,0,0,0,1,0, 1,0,0,0,0,0,1,0, 0,0,0,0,0,0,1,0],
98
+ cog:[0,0,0,1,1,0,0,0, 0,0,1,0,0,1,0,0, 0,1,0,1,1,0,1,0, 1,0,0,0,0,0,0,1, 1,0,0,0,0,0,0,1, 0,1,0,1,1,0,1,0, 0,0,1,0,0,1,0,0, 0,0,0,1,1,0,0,0],
99
+ table:[1,1,1,1,1,1,1,1, 1,0,0,0,0,0,0,1, 1,0,1,0,0,0,0,1, 1,0,0,0,0,0,0,1, 1,1,1,1,1,1,1,1, 1,0,0,0,0,0,0,1, 1,0,0,0,0,0,0,1, 1,1,1,1,1,1,1,1],
100
+ globe:[0,0,1,1,1,1,0,0, 0,1,0,1,1,0,1,0, 1,0,0,0,0,0,0,1, 1,0,0,1,1,0,0,1, 1,0,0,0,0,0,0,1, 1,0,1,0,0,1,0,1, 0,1,0,0,0,0,1,0, 0,0,1,1,1,1,0,0],
101
+ play:[0,0,0,0,0,0,0,0, 0,1,1,0,0,0,0,0, 0,1,1,1,0,0,0,0, 0,1,1,1,1,0,0,0, 0,1,1,1,1,1,0,0, 0,1,1,1,0,0,0,0, 0,1,1,0,0,0,0,0, 0,0,0,0,0,0,0,0],
102
+ file:[1,1,1,1,1,0,0,0, 1,0,0,0,1,0,0,0, 1,0,0,0,1,0,0,0, 1,0,0,0,1,0,0,0, 1,0,0,0,1,0,0,0, 1,0,0,0,1,0,0,0, 1,1,1,1,1,0,0,0, 1,1,1,1,1,1,1,0],
103
+ map:[0,0,0,1,1,0,0,0, 0,0,1,0,0,1,0,0, 0,1,0,1,1,0,1,0, 1,0,0,0,0,0,0,1, 1,0,0,1,1,0,0,1, 0,1,0,0,0,0,1,0, 0,0,1,0,0,1,0,0, 0,0,0,1,1,0,0,0],
104
+ list:[1,1,1,1,1,0,0,0, 1,0,0,0,0,0,0,0, 1,0,0,0,0,0,0,0, 0,1,1,1,1,0,0,0, 0,0,0,0,0,1,0,0, 0,0,0,0,0,1,0,0, 0,0,0,0,0,1,0,0, 1,1,1,1,1,1,0,0],
105
+ shield:[0,0,0,0,0,0,0,0, 0,1,1,1,1,1,1,0, 1,0,0,0,0,0,0,1, 1,0,0,1,1,0,0,1, 1,0,0,1,1,0,0,1, 1,0,0,0,0,0,0,1, 0,1,0,0,0,0,1,0, 0,0,1,1,1,1,0,0],
106
+ eye:[0,0,0,0,0,0,0,0, 0,1,1,1,0,1,1,0, 1,0,0,1,0,0,0,1, 1,0,0,1,0,0,0,1, 0,1,1,1,0,1,1,0, 0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0],
107
+ };
108
+
109
+ const ICON_COLORS = {
110
+ brain:'#bc8cff', layout:'#58a6ff', palette:'#FF6B35', database:'#3fb950', test:'#d2991d',
111
+ server:'#58a6ff', route:'#FF6B35', cog:'#8b949e', table:'#3fb950', globe:'#58a6ff',
112
+ play:'#3fb950', file:'#8b949e', map:'#bc8cff', list:'#d2991d', shield:'#FF6B35', eye:'#bc8cff',
113
+ };
114
+
115
+ function pixelAvatar(icon) {
116
+ const pattern = PIXEL_PATTERNS[icon] || PIXEL_PATTERNS.brain;
117
+ const color = ICON_COLORS[icon] || ICON_COLORS.brain;
118
+ return `<div class=avatar-8>${pattern.map(v => `<span style="background:${v?color:'transparent'}"></span>`).join('')}</div>`;
119
+ }
120
+
121
+ async function load() {
122
+ try {
123
+ const r = await fetch('/api/agents');
124
+ const data = await r.json();
125
+ agents = data.agents;
126
+ renderAgents();
127
+ // Model filter buttons
128
+ const models = [...new Set(agents.map(a=>a.model))];
129
+ document.getElementById('modelFilter').innerHTML = [
130
+ `<button class="${currentFilter==='all'?'sel':''}" onclick="filterBy('all')">All</button>`,
131
+ ...models.map(m => `<button class="${currentFilter===m?'sel':''}" onclick="filterBy('${m}')">${m}</button>`)
132
+ ].join('');
133
+ } catch(e) { console.error(e); }
134
+ }
135
+
136
+ function renderAgents() {
137
+ const filtered = currentFilter === 'all' ? agents : agents.filter(a => a.model === currentFilter);
138
+ document.getElementById('agentCount').textContent = filtered.length + ' agents';
139
+ document.getElementById('agentsGrid').innerHTML = filtered.map(a =>
140
+ `<div class="pixel-card${a.is_custom?' custom':''}" onclick="openModal('${a.id}')">
141
+ ${pixelAvatar(a.icon)}
142
+ <div class=role-tag>${a.role}</div>
143
+ <div class=agent-name>${a.name}</div>
144
+ <div class="model-name${a.is_custom?' custom':''}">${a.model}</div>
145
+ </div>`
146
+ ).join('');
147
+ }
148
+
149
+ function filterBy(model) { currentFilter = model; renderAgents(); load(); }
150
+
151
+ function openModal(id) {
152
+ currentAgent = agents.find(a => a.id === id);
153
+ if (!currentAgent) return;
154
+ document.getElementById('modalName').textContent = currentAgent.name;
155
+ document.getElementById('modalRole').textContent = currentAgent.role + ' · Default: ' + currentAgent.defaultModel;
156
+ const sel = document.getElementById('modalSelect');
157
+ sel.innerHTML = data?.available_models?.map(m => `<option value="${m}" ${m===currentAgent.model?'selected':''}>${m}</option>`).join('') || '';
158
+ document.getElementById('modal').classList.add('show');
159
+ }
160
+
161
+ async function loadModels() {
162
+ const r = await fetch('/api/agents'); data = await r.json();
163
+ agents = data.agents;
164
+ const sel = document.getElementById('modalSelect');
165
+ sel.innerHTML = data.available_models.map(m => `<option value="${m}">${m}</option>`).join('');
166
+ renderAgents();
167
+ }
168
+
169
+ let data = null;
170
+
171
+ async function saveModel() {
172
+ const model = document.getElementById('modalSelect').value;
173
+ await fetch('/api/agents', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({agent_id:currentAgent.id, model}) });
174
+ document.getElementById('modal').classList.remove('show');
175
+ toast(`💾 ${currentAgent.name} → ${model}`);
176
+ loadModels();
177
+ }
178
+
179
+ async function resetModel() {
180
+ await fetch('/api/agents', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({agent_id:currentAgent.id, model:currentAgent.defaultModel}) });
181
+ document.getElementById('modal').classList.remove('show');
182
+ toast(`↩ ${currentAgent.name} → default (${currentAgent.defaultModel})`);
183
+ loadModels();
184
+ }
185
+
186
+ document.getElementById('modal').addEventListener('click', e => { if (e.target === e.currentTarget) document.getElementById('modal').classList.remove('show'); });
187
+
188
+ function toast(msg) {
189
+ const t = document.createElement('div'); t.className='toast'; t.textContent=msg;
190
+ document.getElementById('toastContainer').appendChild(t);
191
+ setTimeout(() => t.remove(), 2500);
192
+ }
193
+
194
+ loadModels();
195
+ </script>
196
+ </body>
197
+ </html>
@@ -38,6 +38,43 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
38
38
 
39
39
  const server = new McpServer({ name: 'jarvis-engine', version: readPkgVersion() });
40
40
 
41
+ // ---- Session Manager (multi-session safety) ----
42
+ const sessions = new Map(); // sessionId → { id, platform, created_at, last_heartbeat, role }
43
+ const SESSION_TIMEOUT = 120_000; // 2 min heartbeat timeout
44
+ let leaderSessionId = null;
45
+
46
+ function cleanupStaleSessions() {
47
+ const now = Date.now();
48
+ for (const [sid, s] of sessions) {
49
+ if (now - s.last_heartbeat > SESSION_TIMEOUT) {
50
+ sessions.delete(sid);
51
+ if (sid === leaderSessionId) {
52
+ leaderSessionId = null;
53
+ // Elect new leader
54
+ const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
55
+ if (oldest) { leaderSessionId = oldest.id; oldest.role = 'leader'; }
56
+ }
57
+ }
58
+ }
59
+ // If no leader but sessions exist, elect oldest
60
+ if (!leaderSessionId && sessions.size > 0) {
61
+ const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
62
+ leaderSessionId = oldest.id;
63
+ oldest.role = 'leader';
64
+ }
65
+ }
66
+
67
+ function requireLeader(sessionId) {
68
+ cleanupStaleSessions();
69
+ const s = sessions.get(sessionId);
70
+ if (!s) return { error: 'Session not registered. Call session_join first.' };
71
+ if (s.role !== 'leader') return { error: `Write lock held by session ${leaderSessionId}. You are observer (read-only).` };
72
+ return null;
73
+ }
74
+
75
+ // Heartbeat cleanup
76
+ setInterval(cleanupStaleSessions, 30_000);
77
+
41
78
  // ---- Pipeline state machine (hard constraints) ----
42
79
  const pipelinePath = join(root, '.jarvis', 'pipeline.json');
43
80
 
@@ -54,17 +91,78 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
54
91
  // TOOLS
55
92
  // ==============================
56
93
 
94
+ // --- Session management ---
95
+ server.tool(
96
+ 'session_join',
97
+ '【多会话安全】注册当前会话。第一个注册的会话获得 leader 写锁,后续会话为 observer(只读)。返回 session_id 和角色。',
98
+ { platform: z.enum(['claude','opencode','codex','other']).optional().describe('平台名称') },
99
+ async ({ platform }, extra) => {
100
+ const sessionId = extra?.sessionId || `s${Date.now()}`;
101
+ cleanupStaleSessions();
102
+ const existing = sessions.get(sessionId);
103
+ if (existing) { existing.last_heartbeat = Date.now(); return { content: [{ type: 'text', text: JSON.stringify({ session_id: sessionId, role: existing.role, leader: leaderSessionId, active_sessions: sessions.size }) }] }; }
104
+
105
+ const role = sessions.size === 0 ? 'leader' : 'observer';
106
+ const s = { id: sessionId, platform: platform || 'unknown', created_at: Date.now(), last_heartbeat: Date.now(), role };
107
+ sessions.set(sessionId, s);
108
+ if (role === 'leader') leaderSessionId = sessionId;
109
+
110
+ return {
111
+ content: [{ type: 'text', text: JSON.stringify({
112
+ session_id: sessionId, role, leader: leaderSessionId,
113
+ active_sessions: sessions.size,
114
+ message: role === 'leader' ? '🔑 You are leader — write access granted.' : '👁 You are observer — read-only. Leader holds write lock.',
115
+ }, null, 2) }],
116
+ };
117
+ }
118
+ );
119
+
120
+ server.tool('session_heartbeat', '【多会话安全】发送心跳,保持会话活跃。每 60 秒至少发一次,否则会话超时被清理。', {},
121
+ async (_args, extra) => {
122
+ const sid = extra?.sessionId;
123
+ if (!sid || !sessions.has(sid)) return { content: [{ type: 'text', text: JSON.stringify({ error: 'Session not found. Call session_join first.' }) }] };
124
+ sessions.get(sid).last_heartbeat = Date.now();
125
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, session_id: sid, role: sessions.get(sid).role, leader: leaderSessionId }) }] };
126
+ }
127
+ );
128
+
129
+ server.tool('session_list', '【多会话安全】列出所有活跃会话及其角色。', {},
130
+ async () => {
131
+ cleanupStaleSessions();
132
+ const list = [...sessions.values()].map(s => ({ session_id: s.id, platform: s.platform, role: s.role, leader: s.id === leaderSessionId, last_heartbeat_ago: `${Math.round((Date.now() - s.last_heartbeat) / 1000)}s` }));
133
+ return { content: [{ type: 'text', text: JSON.stringify({ active_sessions: sessions.size, leader_session: leaderSessionId, sessions: list }) }] };
134
+ }
135
+ );
136
+
137
+ server.tool('session_leave', '【多会话安全】主动离开。如果是 leader,锁自动移交给最老的 observer。', {},
138
+ async (_args, extra) => {
139
+ const sid = extra?.sessionId;
140
+ if (!sid || !sessions.has(sid)) return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Session not registered.' }) }] };
141
+ const wasLeader = sessions.get(sid).role === 'leader';
142
+ sessions.delete(sid);
143
+ if (wasLeader) {
144
+ leaderSessionId = null;
145
+ const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
146
+ if (oldest) { leaderSessionId = oldest.id; oldest.role = 'leader'; }
147
+ }
148
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Session left.', lock_transferred: wasLeader && leaderSessionId ? `Lock → ${leaderSessionId}` : 'No active sessions' }) }] };
149
+ }
150
+ );
151
+
152
+ // --- Pipeline management ---
153
+
57
154
  // Tool: pipeline_init — hard state bootstrap
58
155
  server.tool(
59
156
  'pipeline_init',
60
- '【硬约束】初始化流水线状态机。项目启动时必须调用。创建 pipeline.json,设置当前 Gate 为 Gate A。已初始化则返回当前状态。',
157
+ '【硬约束·需Leader】初始化流水线状态机。只有 leader 会话可调用。observer 会被拒绝。',
61
158
  { project_name: z.string().optional().describe('项目名称(可选)') },
62
- async ({ project_name }) => {
159
+ async ({ project_name }, extra) => {
160
+ const lockErr = requireLeader(extra?.sessionId); if (lockErr) return { content: [{ type: 'text', text: JSON.stringify(lockErr) }] };
63
161
  const existing = readPipeline();
64
- if (existing) return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline already initialized', state: existing }, null, 2) }] };
65
- const state = { project: project_name || root, current_gate: 'Gate A', started_at: new Date().toISOString(), gates_passed: [], mode: 'strict' };
162
+ if (existing) return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline already initialized', state: existing, session: extra?.sessionId, role: 'leader' }, null, 2) }] };
163
+ const state = { project: project_name || root, current_gate: 'Gate A', started_at: new Date().toISOString(), gates_passed: [], mode: 'strict', initialized_by: extra?.sessionId };
66
164
  writePipeline(state);
67
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline initialized — hard state machine active. Next: Gate A', state }, null, 2) }] };
165
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline initialized — hard state machine active. Next: Gate A', state, session: extra?.sessionId, role: 'leader' }, null, 2) }] };
68
166
  }
69
167
  );
70
168
 
@@ -80,6 +178,7 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
80
178
  return { gate, passed: checkpoints.length > 0, checkpoints, artifacts: findGateArtifacts(join(root, 'docs'), gate), requirement: GATE_CHECKS[gate]?.check || '' };
81
179
  });
82
180
  const current = pstate?.current_gate || (gates.find(g => !g.passed)?.gate || 'Gate A');
181
+ const sessionInfo = { active_sessions: sessions.size, leader: leaderSessionId, sessions: [...sessions.values()].map(s => ({ id: s.id, role: s.role, platform: s.platform, alive_s: Math.round((Date.now() - s.last_heartbeat)/1000) })) };
83
182
  return {
84
183
  content: [{ type: 'text', text: JSON.stringify({
85
184
  project: root,
@@ -87,6 +186,7 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
87
186
  current_gate: current,
88
187
  completed: gates.filter(g => g.passed).map(g => g.gate),
89
188
  gates,
189
+ sessions: sessionInfo,
90
190
  _display: formatGateDisplay(gates, current),
91
191
  }, null, 2) }],
92
192
  };
@@ -127,12 +227,13 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
127
227
  }
128
228
  );
129
229
 
130
- // Tool: advance_gate — FSM enforced
230
+ // Tool: advance_gate — FSM enforced + leader check
131
231
  server.tool(
132
232
  'advance_gate',
133
- '【硬约束】推进到下一个 Gate。仅当当前 Gate gate_enforce 返回 allowed=true 时才允许推进。非顺序推进(跳过 Gate)会被拒绝。',
233
+ '【硬约束·需Leader】推进到下一个 Gate。仅 leader 会话可调用,observer 被拒绝。非顺序推进被 FSM 拒绝。',
134
234
  { gate: z.enum(GATES).describe('要推进到的 Gate 名称') },
135
- async ({ gate }) => {
235
+ async ({ gate }, extra) => {
236
+ const lockErr = requireLeader(extra?.sessionId); if (lockErr) return { content: [{ type: 'text', text: JSON.stringify(lockErr) }] };
136
237
  const pstate = readPipeline();
137
238
  const currentGate = pstate?.current_gate || 'Gate A';
138
239
  const currentIdx = GATES.indexOf(currentGate);
@@ -265,6 +366,77 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
265
366
  // ---- Health ----
266
367
  app.get('/health', (_req, res) => res.json({ status: 'ok', version: readPkgVersion(), tools: ['pipeline_init', 'pipeline_status', 'gate_enforce', 'advance_gate', 'report_status'] }));
267
368
 
369
+ // ---- Agent Model Config ----
370
+ const agentConfigPath = join(root, '.jarvis', 'agent-models.json');
371
+
372
+ function readAgentConfig() {
373
+ if (!existsSync(agentConfigPath)) return {};
374
+ try { return JSON.parse(readFileSync(agentConfigPath, 'utf-8')); } catch { return {}; }
375
+ }
376
+ function writeAgentConfig(cfg) {
377
+ const dir = join(root, '.jarvis'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
378
+ writeFileSync(agentConfigPath, JSON.stringify(cfg, null, 2));
379
+ }
380
+
381
+ const AVAILABLE_MODELS = [
382
+ 'deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek/deepseek-v4-pro', 'deepseek/deepseek-v4-flash',
383
+ 'gpt-5.5', 'gpt-5.4', 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.4-mini', 'gpt-5.2',
384
+ 'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5',
385
+ ];
386
+
387
+ const AGENT_LIST = [
388
+ { id:'jarvis', name:'Jarvis', role:'编排中枢', icon:'brain', defaultModel:'deepseek-v4-pro' },
389
+ { id:'frontend-implementer', name:'Frontend', role:'前端全栈', icon:'layout', defaultModel:'deepseek-v4-pro' },
390
+ { id:'frontend-ui-worker', name:'UI Worker', role:'UI/样式', icon:'palette', defaultModel:'deepseek-v4-flash' },
391
+ { id:'frontend-state-worker', name:'State Worker', role:'状态/数据', icon:'database', defaultModel:'deepseek-v4-flash' },
392
+ { id:'frontend-test-worker', name:'Frontend Test', role:'前端测试', icon:'test', defaultModel:'deepseek-v4-flash' },
393
+ { id:'backend-implementer', name:'Backend', role:'后端全栈', icon:'server', defaultModel:'deepseek-v4-pro' },
394
+ { id:'backend-api-worker', name:'API Worker', role:'API/路由', icon:'route', defaultModel:'deepseek-v4-flash' },
395
+ { id:'backend-service-worker', name:'Service Worker', role:'业务逻辑', icon:'cog', defaultModel:'deepseek-v4-flash' },
396
+ { id:'backend-data-worker', name:'Data Worker', role:'数据层', icon:'table', defaultModel:'deepseek-v4-flash' },
397
+ { id:'backend-test-worker', name:'Backend Test', role:'后端测试', icon:'test', defaultModel:'deepseek-v4-flash' },
398
+ { id:'browser-test-worker', name:'Browser Test', role:'浏览器测试', icon:'globe', defaultModel:'deepseek-v4-flash' },
399
+ { id:'e2e-test-worker', name:'E2E Test', role:'端到端测试', icon:'play', defaultModel:'deepseek-v4-flash' },
400
+ { id:'api-docs-worker', name:'API Docs', role:'API文档', icon:'file', defaultModel:'deepseek-v4-flash' },
401
+ { id:'planner', name:'Planner', role:'执行规划', icon:'map', defaultModel:'deepseek-v4-pro' },
402
+ { id:'task-design', name:'Task Design', role:'任务分解', icon:'list', defaultModel:'deepseek-v4-pro' },
403
+ { id:'security-auditor', name:'Security', role:'安全审计', icon:'shield', defaultModel:'deepseek-v4-pro' },
404
+ { id:'review-qa', name:'Review QA', role:'评审', icon:'eye', defaultModel:'deepseek-v4-pro' },
405
+ ];
406
+
407
+ // REST: agent config
408
+ app.get('/api/agents', (_req, res) => {
409
+ const cfg = readAgentConfig();
410
+ const list = AGENT_LIST.map(a => ({ ...a, model: cfg[a.id] || a.defaultModel }));
411
+ res.json({ agents: list, available_models: AVAILABLE_MODELS });
412
+ });
413
+
414
+ app.post('/api/agents', (req, res) => {
415
+ const { agent_id, model } = req.body;
416
+ if (!agent_id || !model) return res.status(400).json({ error: 'agent_id and model required' });
417
+ if (!AVAILABLE_MODELS.includes(model)) return res.status(400).json({ error: `Unknown model. Available: ${AVAILABLE_MODELS.join(', ')}` });
418
+ const cfg = readAgentConfig();
419
+ cfg[agent_id] = model;
420
+ writeAgentConfig(cfg);
421
+ res.json({ ok: true, agent_id, model });
422
+ });
423
+
424
+ // MCP: agent_config
425
+ server.tool('agent_config', '配置子 Agent 模型。读取/设置特定 Agent 的模型。', {
426
+ agent_id: z.string().optional().describe('Agent ID(不传则列出全部)'),
427
+ model: z.string().optional().describe('模型名(不传则只读当前配置)'),
428
+ }, async ({ agent_id, model }) => {
429
+ const cfg = readAgentConfig();
430
+ if (agent_id && model) {
431
+ if (!AVAILABLE_MODELS.includes(model)) return { content: [{ type: 'text', text: JSON.stringify({ error: `Unknown model. Available: ${AVAILABLE_MODELS.join(', ')}` }) }] };
432
+ cfg[agent_id] = model;
433
+ writeAgentConfig(cfg);
434
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, agent_id, model, message: `${agent_id} → ${model}` }) }] };
435
+ }
436
+ const list = AGENT_LIST.map(a => ({ id: a.id, name: a.name, role: a.role, model: cfg[a.id] || a.defaultModel, is_custom: !!cfg[a.id] }));
437
+ return { content: [{ type: 'text', text: JSON.stringify({ agents: list, available_models: AVAILABLE_MODELS }) }] };
438
+ });
439
+
268
440
  // ---- SSE (real-time pipeline events) ----
269
441
  const sseClients = new Set();
270
442
  app.get('/api/events', (req, res) => {
@@ -284,8 +456,8 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
284
456
 
285
457
  // ---- Dashboard ----
286
458
  if (dashboard) {
287
- const dashHtml = readFileSync(resolve(import.meta.dirname, 'dashboard.html'), 'utf-8');
288
- app.get('/dashboard', (_req, res) => res.type('html').send(dashHtml));
459
+ app.get('/dashboard', (_req, res) => res.type('html').send(readFileSync(resolve(import.meta.dirname, 'dashboard.html'), 'utf-8')));
460
+ app.get('/agents', (_req, res) => res.type('html').send(readFileSync(resolve(import.meta.dirname, 'agents.html'), 'utf-8')));
289
461
  }
290
462
 
291
463
  app.listen(port, () => {