jarvis-agent-factory 3.22.2 → 3.23.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.22.2",
3
+ "version": "3.23.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",
package/src/engine/db.js CHANGED
@@ -1,11 +1,16 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { join } from 'node:path';
2
+ import { resolve } from 'node:path';
3
3
  import { existsSync, mkdirSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
4
5
 
5
- export function openDb(root) {
6
- const dir = join(root, '.jarvis');
6
+ /**
7
+ * 打开引擎数据库,固定存储在 ~/.jarvis/engine.db
8
+ * @returns {DatabaseSync}
9
+ */
10
+ export function openDb() {
11
+ const dir = resolve(homedir(), '.jarvis');
7
12
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
8
- const db = new DatabaseSync(join(dir, 'engine.db'));
13
+ const db = new DatabaseSync(resolve(dir, 'engine.db'));
9
14
  db.exec('PRAGMA journal_mode=WAL');
10
15
  db.exec('PRAGMA busy_timeout=5000');
11
16
  initSchema(db);
@@ -45,6 +50,20 @@ function initSchema(db) {
45
50
  updated_at TEXT NOT NULL
46
51
  );
47
52
  `);
53
+ // pipeline_runs: 每次 /jarvis 调用产生独立运行记录(Session Model B)
54
+ db.exec(`
55
+ CREATE TABLE IF NOT EXISTS pipeline_runs (
56
+ id TEXT PRIMARY KEY,
57
+ session_id TEXT NOT NULL,
58
+ project TEXT NOT NULL,
59
+ pipeline_type TEXT NOT NULL DEFAULT 'full',
60
+ current_gate TEXT NOT NULL DEFAULT 'Gate A',
61
+ status TEXT NOT NULL DEFAULT 'active',
62
+ started_at TEXT NOT NULL,
63
+ completed_at TEXT
64
+ );
65
+ CREATE INDEX IF NOT EXISTS idx_pipeline_runs_session ON pipeline_runs(session_id, started_at DESC);
66
+ `);
48
67
  try { db.exec("ALTER TABLE agent_models ADD COLUMN effort TEXT NOT NULL DEFAULT 'high'"); } catch {}
49
68
 
50
69
  // ---- 迁移:修复旧 pipeline 表 CHECK(id=1) 约束 ----
@@ -113,14 +132,30 @@ function initSchema(db) {
113
132
 
114
133
  // ---- 旧列迁移(向后兼容) ----
115
134
  try { db.exec("ALTER TABLE sessions ADD COLUMN status TEXT DEFAULT 'active'"); } catch {}
135
+
136
+ // ---- 迁移旧 pipeline 数据为首条 pipeline_run ----
137
+ const existingRuns = db.prepare('SELECT COUNT(*) as cnt FROM pipeline_runs').get();
138
+ if (existingRuns.cnt === 0) {
139
+ const oldPipelines = db.prepare('SELECT * FROM pipeline').all();
140
+ for (const p of oldPipelines) {
141
+ if (!p.session_id) continue;
142
+ const runId = 'run_' + Date.now() + '_' + p.session_id.slice(-6);
143
+ db.prepare(`INSERT OR IGNORE INTO pipeline_runs (id, session_id, project, pipeline_type, current_gate, status, started_at)
144
+ VALUES (?, ?, ?, ?, ?, 'active', ?)`).run(
145
+ runId, p.session_id, p.project || 'jarvis', p.pipeline_type || 'full', p.current_gate || 'Gate A', p.started_at || new Date().toISOString()
146
+ );
147
+ }
148
+ }
116
149
  }
117
150
 
118
151
  // ---- Pipeline (per-session) ----
119
152
  export function getPipeline(db, sessionId) {
120
- return db.prepare('SELECT * FROM pipeline WHERE session_id=?').get(sessionId || 'legacy');
153
+ if (!sessionId) return null;
154
+ return db.prepare('SELECT * FROM pipeline WHERE session_id=?').get(sessionId);
121
155
  }
122
156
  export function updatePipelineGate(db, sessionId, gate) {
123
- db.prepare(`UPDATE pipeline SET current_gate=?, updated_at=datetime('now') WHERE session_id=?`).run(gate, sessionId || 'legacy');
157
+ if (!sessionId) throw new Error('session_id required');
158
+ db.prepare(`UPDATE pipeline SET current_gate=?, updated_at=datetime('now') WHERE session_id=?`).run(gate, sessionId);
124
159
  }
125
160
  /** @param {'full'|'frontend'|'backend'} pipelineType */
126
161
  export function initPipeline(db, sessionId, project, pipelineType = 'full') {
@@ -132,8 +167,9 @@ export function getAllPipelines(db) {
132
167
 
133
168
  // ---- Checkpoints (per-session) ----
134
169
  export function getCheckpoints(db, gate, sessionId) {
135
- if (gate) return db.prepare('SELECT * FROM checkpoints WHERE gate=? AND session_id=?').all(gate, sessionId || 'legacy');
136
- return db.prepare('SELECT * FROM checkpoints WHERE session_id=? ORDER BY passed_at').all(sessionId || 'legacy');
170
+ if (!sessionId) return [];
171
+ if (gate) return db.prepare('SELECT * FROM checkpoints WHERE gate=? AND session_id=?').all(gate, sessionId);
172
+ return db.prepare('SELECT * FROM checkpoints WHERE session_id=? ORDER BY passed_at').all(sessionId);
137
173
  }
138
174
  export function addCheckpoint(db, gate, advanceTo, sessionId) {
139
175
  db.prepare(`INSERT OR REPLACE INTO checkpoints (session_id, gate, passed_at, advance_to) VALUES (?, ?, datetime('now'), ?)`).run(sessionId, gate, advanceTo);
@@ -190,3 +226,56 @@ export function getAgentConfig(db) {
190
226
  export function setAgentModel(db, agentId, model, effort) {
191
227
  db.prepare(`INSERT OR REPLACE INTO agent_models (agent_id, model, effort, updated_at) VALUES (?, ?, ?, datetime('now'))`).run(agentId, model, effort || 'high');
192
228
  }
229
+
230
+ // ---- Pipeline Runs(Session Model B)----
231
+
232
+ /**
233
+ * 创建新的 pipeline run
234
+ * @param {DatabaseSync} db
235
+ * @param {string} sessionId
236
+ * @param {string} project
237
+ * @param {string} [pipelineType='full']
238
+ * @returns {string} runId
239
+ */
240
+ export function createPipelineRun(db, sessionId, project, pipelineType = 'full') {
241
+ const id = 'run_' + Date.now();
242
+ db.prepare(`INSERT INTO pipeline_runs (id, session_id, project, pipeline_type, current_gate, status, started_at)
243
+ VALUES (?, ?, ?, ?, 'Gate A', 'active', datetime('now'))`).run(id, sessionId, project, pipelineType);
244
+ return id;
245
+ }
246
+
247
+ /** 获取指定 run */
248
+ export function getPipelineRun(db, runId) {
249
+ return db.prepare('SELECT * FROM pipeline_runs WHERE id=?').get(runId);
250
+ }
251
+
252
+ /**
253
+ * 获取 session 的当前活跃 run(最新一条 status=active)
254
+ * @returns {object|undefined}
255
+ */
256
+ export function getActiveRun(db, sessionId) {
257
+ return db.prepare("SELECT * FROM pipeline_runs WHERE session_id=? AND status='active' ORDER BY started_at DESC LIMIT 1").get(sessionId);
258
+ }
259
+
260
+ /**
261
+ * 获取 session 的所有 runs(按时间倒序)
262
+ * @returns {object[]}
263
+ */
264
+ export function getSessionRuns(db, sessionId) {
265
+ return db.prepare('SELECT * FROM pipeline_runs WHERE session_id=? ORDER BY started_at DESC').all(sessionId);
266
+ }
267
+
268
+ /** 更新 run 的当前 Gate */
269
+ export function updateRunGate(db, runId, gate) {
270
+ db.prepare("UPDATE pipeline_runs SET current_gate=? WHERE id=?").run(gate, runId);
271
+ }
272
+
273
+ /** 完成 run */
274
+ export function completeRun(db, runId) {
275
+ db.prepare("UPDATE pipeline_runs SET status='completed', completed_at=datetime('now') WHERE id=?").run(runId);
276
+ }
277
+
278
+ /** 中止 run */
279
+ export function abortRun(db, runId) {
280
+ db.prepare("UPDATE pipeline_runs SET status='aborted', completed_at=datetime('now') WHERE id=?").run(runId);
281
+ }
@@ -81,7 +81,7 @@ export const GATE_CHECKS = {
81
81
  export const GATE_OPERATIONS = {
82
82
  'Gate A': { allow: ['read','write_doc'], deny: ['write_code','spawn_impl','spawn_test','build','deploy'] },
83
83
  'Gate B': { allow: ['read','write_doc'], deny: ['write_code','spawn_impl','spawn_test','build','deploy'] },
84
- 'Gate C': { allow: ['read','write_doc','sweep_arch'], deny: ['write_code','spawn_impl','spawn_test','build','deploy'] },
84
+ 'Gate C': { allow: ['read','write_doc','sweep_arch','write_code','spawn_impl'], deny: ['spawn_test','build','deploy'] },
85
85
  'Gate C1': { allow: ['read','lint','build','fix'], deny: ['spawn_impl','spawn_test','deploy','write_code'] },
86
86
  'Gate C1.5': { allow: ['read','preview','fix'], deny: ['spawn_impl','spawn_test','build','deploy','write_code'] },
87
87
  'Gate C2': { allow: ['read','spawn_test','fix'], deny: ['spawn_impl','deploy','write_code'] },
@@ -4,12 +4,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
6
  import { z } from 'zod';
7
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, copyFileSync } from 'node:fs';
8
8
  import { resolve, join } from 'node:path';
9
9
  import { homedir } from 'node:os';
10
10
  import { createServer } from 'node:net';
11
11
  import { createServer as createHttpServer } from 'node:http';
12
- import { openDb, getPipeline, initPipeline, getCheckpoints, addCheckpoint, updatePipelineGate, getSessions, getSession, addSession, heartbeatSession, removeSession, markStaleSessions, resumeSession, migrateSession, getAllPipelines, getAgentConfig, setAgentModel } from './db.js';
12
+ import { openDb, getPipeline, initPipeline, getCheckpoints, addCheckpoint, updatePipelineGate, getSessions, getSession, addSession, heartbeatSession, removeSession, markStaleSessions, resumeSession, migrateSession, getAllPipelines, getAgentConfig, setAgentModel, createPipelineRun, getPipelineRun, getActiveRun, getSessionRuns, updateRunGate, completeRun } from './db.js';
13
13
  import { GATE_CHECKS, GATE_DIRS, AGENT_LIST, PIPELINE_DEFS, findGateArtifacts, formatGateDisplay, getPipelineGates, getPipelineName, getGateOperations, DEFAULT_PIPELINE } from './gates.js';
14
14
  import { getAgentsByPlatform, getPlatforms, getPlatformModels, getAgentList } from './agent-registry.js';
15
15
  import { setupApiRoutes } from '../web/routes.js';
@@ -17,7 +17,10 @@ import { setupApiRoutes } from '../web/routes.js';
17
17
  const PID_FILE = resolve(homedir(), '.jarvis', 'engine.pid');
18
18
  const DEFAULT_PORT = 3456;
19
19
  const DEFAULT_WEB_PORT = 3457;
20
- const SESSION_TIMEOUT = 600_000; // 10分钟超时,标记 inactive 而非删除
20
+ const SESSION_TIMEOUT = 1_800_000; // 30分钟超时,标记 inactive 而非删除
21
+
22
+ /** stdio 模式下 extra?.sessionId 为空,用此变量记录最近一次 session_join 的会话 ID */
23
+ let _lastSessionId = null;
21
24
 
22
25
  /** 绑定地址:仅 IPv4 本地回环 */
23
26
  const BIND_HOST = '127.0.0.1';
@@ -57,12 +60,32 @@ export async function startEngine({ port = DEFAULT_PORT, projectRoot = '.', stdi
57
60
  if (!existsSync(pidDir)) mkdirSync(pidDir, { recursive: true });
58
61
  writeFileSync(PID_FILE, String(process.pid));
59
62
 
63
+ // 旧数据库迁移:从 <projectRoot>/.jarvis/ 移动到 ~/.jarvis/
64
+ const oldDbPath = resolve(root, '.jarvis', 'engine.db');
65
+ const newDbPath = resolve(homedir(), '.jarvis', 'engine.db');
66
+ if (existsSync(oldDbPath) && !existsSync(newDbPath)) {
67
+ copyFileSync(oldDbPath, newDbPath);
68
+ for (const suffix of ['-wal', '-shm']) {
69
+ const oldAux = oldDbPath + suffix;
70
+ const newAux = newDbPath + suffix;
71
+ if (existsSync(oldAux)) copyFileSync(oldAux, newAux);
72
+ }
73
+ console.log(' ✓ 旧数据库已迁移: ' + oldDbPath + ' → ' + newDbPath);
74
+ }
75
+
60
76
  const app = new Hono();
61
77
  const mcpServer = new McpServer({ name: 'jarvis-engine', version: readPkgVersion() });
62
- const db = openDb(root);
78
+ const db = openDb();
63
79
 
64
80
  // 心跳保活 + 过期标记
65
81
  setInterval(() => { markStaleSessions(db, SESSION_TIMEOUT); }, 30_000);
82
+ // 引擎内部自动心跳:每 5 分钟对所有 active 会话更新心跳,防止 stdio 模式下心跳丢失
83
+ setInterval(() => {
84
+ const activeSessions = getSessions(db, 'active');
85
+ for (const s of activeSessions) {
86
+ heartbeatSession(db, s.id);
87
+ }
88
+ }, 300_000);
66
89
 
67
90
  // ---- MCP Tools (不变) ----
68
91
  registerMcpTools(mcpServer, db, root);
@@ -146,7 +169,12 @@ function registerMcpTools(server, db, root) {
146
169
  },
147
170
  async ({ platform, resume_session_id, pipeline_type }, extra) => {
148
171
  const sid = extra?.sessionId || `s${Date.now()}`;
172
+ _lastSessionId = sid; // stdio 模式回退:记录最近会话
149
173
  const pt = pipeline_type || DEFAULT_PIPELINE;
174
+ // 白名单校验 pipeline_type,防止存储型 XSS
175
+ if (!['full', 'frontend', 'backend', 'lite'].includes(pt)) {
176
+ return resp({ error: `Invalid pipeline_type: ${pt}. Valid: full, frontend, backend, lite` });
177
+ }
150
178
  if (resume_session_id) {
151
179
  const old = getSession(db, resume_session_id);
152
180
  if (old) {
@@ -158,20 +186,23 @@ function registerMcpTools(server, db, root) {
158
186
  if (existing) {
159
187
  heartbeatSession(db, sid);
160
188
  const p = getPipeline(db, sid);
189
+ // Session Model B: 无活跃 run 时自动创建
190
+ const runId = getActiveRun(db, sid)?.id || createPipelineRun(db, sid, p?.project || root, p?.pipeline_type || pt);
161
191
  return resp({
162
192
  session_id: sid, platform: existing.platform,
163
193
  gate: p?.current_gate || 'Gate A',
164
194
  pipeline_type: p?.pipeline_type || DEFAULT_PIPELINE,
165
- project: p?.project || root, resumed: false,
195
+ project: p?.project || root, run_id: runId, resumed: false,
166
196
  });
167
197
  }
168
198
  addSession(db, sid, platform || 'unknown', 'member');
169
199
  if (!getPipeline(db, sid)) initPipeline(db, sid, root, pt);
170
200
  const p = getPipeline(db, sid);
201
+ const runId = createPipelineRun(db, sid, p?.project || root, p?.pipeline_type || pt);
171
202
  return resp({
172
203
  session_id: sid, platform: platform || 'unknown',
173
204
  gate: p?.current_gate || 'Gate A',
174
- pipeline_type: pt, project: p?.project || root,
205
+ pipeline_type: pt, project: p?.project || root, run_id: runId,
175
206
  message: '\u{1F195} 新会话已初始化,独立流水线已就绪。',
176
207
  resumed: !!resume_session_id,
177
208
  });
@@ -180,9 +211,17 @@ function registerMcpTools(server, db, root) {
180
211
  server.tool('session_heartbeat', '心跳保活。', {},
181
212
  async (_args, extra) => {
182
213
  const sid = extra?.sessionId;
183
- if (!sid || !getSession(db, sid)) return resp({ error: 'Session not found.' });
184
- heartbeatSession(db, sid);
185
- return resp({ ok: true });
214
+ // stdio 模式下 extra?.sessionId 可能为空,查找最近活跃会话
215
+ if (sid && getSession(db, sid)) {
216
+ heartbeatSession(db, sid);
217
+ return resp({ ok: true, session_id: sid });
218
+ }
219
+ // 回退:更新所有 active 会话的心跳
220
+ const activeSessions = getSessions(db, 'active');
221
+ for (const s of activeSessions) {
222
+ heartbeatSession(db, s.id);
223
+ }
224
+ return resp({ ok: true, heartbeat_count: activeSessions.length });
186
225
  });
187
226
 
188
227
  server.tool('session_list', '列出所有活跃会话。', {}, async () => {
@@ -210,19 +249,24 @@ function registerMcpTools(server, db, root) {
210
249
  server.tool('pipeline_init', '【会话隔离】初始化当前会话流水线。',
211
250
  { project_name: z.string().optional(), pipeline_type: z.string().optional() },
212
251
  async ({ project_name, pipeline_type }, extra) => {
213
- const sid = extra?.sessionId || 'legacy';
252
+ const sid = extra?.sessionId || _lastSessionId;
253
+ if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
214
254
  const pt = pipeline_type || DEFAULT_PIPELINE;
255
+ // Session Model B: 创建新 run,同步更新 pipeline 快照
256
+ const runId = createPipelineRun(db, sid, project_name || root, pt);
215
257
  initPipeline(db, sid, project_name || root, pt);
216
258
  return resp({
217
- ok: true, session_id: sid, pipeline_type: pt,
218
- message: 'Pipeline initialized. Next: Gate A',
259
+ ok: true, session_id: sid, run_id: runId, pipeline_type: pt,
260
+ message: 'New pipeline run created. Next: Gate A',
219
261
  state: getPipeline(db, sid),
220
262
  });
221
263
  });
222
264
 
223
- server.tool('pipeline_status', '【会话隔离】当前会话流水线状态。', {},
224
- async (_args, extra) => {
225
- const sid = extra?.sessionId || 'legacy';
265
+ server.tool('pipeline_status', '【会话隔离】当前会话流水线状态。',
266
+ { run_id: z.string().optional() },
267
+ async ({ run_id }, extra) => {
268
+ const sid = extra?.sessionId || _lastSessionId;
269
+ if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
226
270
  const p = getPipeline(db, sid);
227
271
  const pt = p?.pipeline_type || DEFAULT_PIPELINE;
228
272
  const gateList = getPipelineGates(pt);
@@ -236,12 +280,14 @@ function registerMcpTools(server, db, root) {
236
280
  };
237
281
  });
238
282
  const current = gates.find(g => !g.passed)?.gate || 'Complete';
283
+ const runId = run_id || getActiveRun(db, sid)?.id;
239
284
  return resp({
240
285
  session_id: sid, project: root, pipeline_type: pt,
241
286
  pipeline_name: getPipelineName(pt),
242
287
  current_gate: current,
243
288
  completed: gates.filter(g => g.passed).map(g => g.gate),
244
289
  gates,
290
+ run_id: runId,
245
291
  all_sessions: getSessions(db).map(s => ({
246
292
  id: s.id, gate: getPipeline(db, s.id)?.current_gate || '?',
247
293
  })),
@@ -250,27 +296,31 @@ function registerMcpTools(server, db, root) {
250
296
  });
251
297
 
252
298
  server.tool('gate_enforce', '【会话隔离·硬约束】验证Gate条件。',
253
- { gate: z.string().optional() },
254
- async ({ gate }, extra) => {
255
- const sid = extra?.sessionId || 'legacy';
299
+ { gate: z.string().optional(), run_id: z.string().optional() },
300
+ async ({ gate, run_id }, extra) => {
301
+ const sid = extra?.sessionId || _lastSessionId;
302
+ if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
303
+ const runId = run_id || getActiveRun(db, sid)?.id;
256
304
  const gateList = sessionGates(db, sid);
257
305
  const target = gate || getPipeline(db, sid)?.current_gate || gateList[0];
258
306
  const artifacts = findGateArtifacts(join(root, 'docs'), target);
259
307
  const checkpoints = getCheckpoints(db, target, sid);
260
308
  const allowed = artifacts.length > 0 || checkpoints.length > 0;
261
309
  return resp(allowed
262
- ? { gate: target, allowed: true, session_id: sid, message: `${target} — proceed.` }
310
+ ? { gate: target, allowed: true, session_id: sid, run_id: runId, message: `${target} — proceed.` }
263
311
  : {
264
- gate: target, allowed: false, session_id: sid,
312
+ gate: target, allowed: false, session_id: sid, run_id: runId,
265
313
  blocked_reasons: [artifacts.length ? '' : `No artifacts in docs/${GATE_DIRS[target] || '?'}/`].filter(Boolean),
266
314
  action_required: GATE_CHECKS[target]?.check || '',
267
315
  });
268
316
  });
269
317
 
270
318
  server.tool('advance_gate', '【会话隔离·硬约束】推进Gate。',
271
- { gate: z.string() },
272
- async ({ gate }, extra) => {
273
- const sid = extra?.sessionId || 'legacy';
319
+ { gate: z.string(), run_id: z.string().optional() },
320
+ async ({ gate, run_id }, extra) => {
321
+ const sid = extra?.sessionId || _lastSessionId;
322
+ if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
323
+ const runId = run_id || getActiveRun(db, sid)?.id;
274
324
  const p = getPipeline(db, sid);
275
325
  const gateList = sessionGates(db, sid);
276
326
  const cur = p?.current_gate || gateList[0];
@@ -283,8 +333,10 @@ function registerMcpTools(server, db, root) {
283
333
  if (artifacts.length === 0 && cps.length === 0) return resp({ allowed: false, error: `${cur} conditions NOT met.` });
284
334
  addCheckpoint(db, cur, gate, sid);
285
335
  updatePipelineGate(db, sid, gate);
336
+ // Session Model B: 同步更新 pipeline_runs 中的 current_gate
337
+ if (runId) updateRunGate(db, runId, gate);
286
338
  return resp({
287
- allowed: true, session_id: sid, previous_gate: cur, current_gate: gate,
339
+ allowed: true, session_id: sid, run_id: runId, previous_gate: cur, current_gate: gate,
288
340
  next: gateList[ti + 1] || 'Complete',
289
341
  message: gateList[ti + 1] ? `Next: ${gateList[ti + 1]}` : 'Complete!',
290
342
  });
@@ -292,9 +344,11 @@ function registerMcpTools(server, db, root) {
292
344
 
293
345
  server.tool('gate_jump',
294
346
  '【lite模式·入口跳转】跳过无关Gate直接进入目标Gate。仅当pipeline_type为lite时可用。',
295
- { gate: z.string().describe('目标Gate,如 Gate C / Gate D / Gate E') },
296
- async ({ gate }, extra) => {
297
- const sid = extra?.sessionId || 'legacy';
347
+ { gate: z.string().describe('目标Gate,如 Gate C / Gate D / Gate E'), run_id: z.string().optional() },
348
+ async ({ gate, run_id }, extra) => {
349
+ const sid = extra?.sessionId || _lastSessionId;
350
+ if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
351
+ const runId = run_id || getActiveRun(db, sid)?.id;
298
352
  const p = getPipeline(db, sid);
299
353
  const pt = p?.pipeline_type || DEFAULT_PIPELINE;
300
354
  const def = PIPELINE_DEFS[pt];
@@ -303,15 +357,19 @@ function registerMcpTools(server, db, root) {
303
357
  const ti = gateList.indexOf(gate);
304
358
  if (ti === -1) return resp({ allowed: false, error: `未知 Gate: ${gate}。有效: ${gateList.join(', ')}` });
305
359
  updatePipelineGate(db, sid, gate);
360
+ if (runId) updateRunGate(db, runId, gate);
306
361
  return resp({
307
- allowed: true, session_id: sid, pipeline_type: pt, entry_gate: gate,
362
+ allowed: true, session_id: sid, run_id: runId, pipeline_type: pt, entry_gate: gate,
308
363
  message: `已跳转至 ${gate},跳过了 ${gateList.slice(0, ti).join(', ')}。剩余: ${gateList.slice(ti).join(' → ')}`,
309
364
  });
310
365
  });
311
366
 
312
- server.tool('report_status', '【会话隔离】流水线完整报告。', {},
313
- async (_args, extra) => {
314
- const sid = extra?.sessionId || 'legacy';
367
+ server.tool('report_status', '【会话隔离】流水线完整报告。',
368
+ { run_id: z.string().optional() },
369
+ async ({ run_id }, extra) => {
370
+ const sid = extra?.sessionId || _lastSessionId;
371
+ if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
372
+ const runId = run_id || getActiveRun(db, sid)?.id;
315
373
  const gateList = sessionGates(db, sid);
316
374
  const gates = gateList.map(g => ({
317
375
  gate: g, passed: getCheckpoints(db, g, sid).length > 0,
@@ -319,7 +377,7 @@ function registerMcpTools(server, db, root) {
319
377
  }));
320
378
  const completed = gates.filter(g => g.passed).length;
321
379
  return resp({
322
- session_id: sid, project: root,
380
+ session_id: sid, project: root, run_id: runId,
323
381
  pipeline_type: getPipeline(db, sid)?.pipeline_type || DEFAULT_PIPELINE,
324
382
  progress: `${completed}/${gateList.length}`,
325
383
  current: gates.find(g => !g.passed)?.gate || 'Complete',
@@ -334,17 +392,20 @@ function registerMcpTools(server, db, root) {
334
392
  'spawn_impl', 'spawn_test', 'lint', 'build', 'preview',
335
393
  'review', 'audit', 'deploy', 'fix',
336
394
  ]).describe('要执行的操作类型'),
395
+ run_id: z.string().optional(),
337
396
  },
338
- async ({ operation }, extra) => {
339
- const sid = extra?.sessionId || 'legacy';
397
+ async ({ operation, run_id }, extra) => {
398
+ const sid = extra?.sessionId || _lastSessionId;
399
+ if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
400
+ const runId = run_id || getActiveRun(db, sid)?.id;
340
401
  const p = getPipeline(db, sid);
341
402
  const gateList = sessionGates(db, sid);
342
403
  const cur = p?.current_gate || gateList[0];
343
404
  const ops = getGateOperations(cur);
344
405
  const allowed = ops.allow.includes(operation);
345
- if (allowed) return resp({ allowed: true, gate: cur, operation, session_id: sid, message: `${operation} 在 ${cur} 允许执行` });
406
+ if (allowed) return resp({ allowed: true, gate: cur, operation, session_id: sid, run_id: runId, message: `${operation} 在 ${cur} 允许执行` });
346
407
  return resp({
347
- allowed: false, gate: cur, operation, session_id: sid,
408
+ allowed: false, gate: cur, operation, session_id: sid, run_id: runId,
348
409
  blocked_reasons: [
349
410
  `操作 "${operation}" 在 ${cur} 不被允许`,
350
411
  `允许的操作: ${ops.allow.join(', ')}`,
@@ -357,9 +418,11 @@ function registerMcpTools(server, db, root) {
357
418
 
358
419
  server.tool('pipeline_guide',
359
420
  '【硬约束·流程指引】返回当前Gate的完整上下文:允许/禁止的操作、可生成的Agent类型、下一步行动指南。在不确定下一步做什么时调用。',
360
- {},
361
- async (_args, extra) => {
362
- const sid = extra?.sessionId || 'legacy';
421
+ { run_id: z.string().optional() },
422
+ async ({ run_id }, extra) => {
423
+ const sid = extra?.sessionId || _lastSessionId;
424
+ if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
425
+ const runId = run_id || getActiveRun(db, sid)?.id;
363
426
  const p = getPipeline(db, sid);
364
427
  const gateList = sessionGates(db, sid);
365
428
  const cur = p?.current_gate || gateList[0];
@@ -379,6 +442,7 @@ function registerMcpTools(server, db, root) {
379
442
  session_id: sid, gate: cur, gate_index: ci + 1, total_gates: gateList.length,
380
443
  pipeline_type: p?.pipeline_type || DEFAULT_PIPELINE,
381
444
  pipeline_name: getPipelineName(p?.pipeline_type || DEFAULT_PIPELINE),
445
+ run_id: runId,
382
446
  allowed_operations: ops.allow, forbidden_operations: ops.deny,
383
447
  agent_spawn: agentGuide[cur] || { can_spawn: [], note: '未知Gate' },
384
448
  gate_requirement: GATE_CHECKS[cur]?.check || '',
package/src/install.js CHANGED
@@ -55,12 +55,11 @@ export async function install({ platform, target, pkgRoot, platforms, force, glo
55
55
  if (!destExists) mkdirSync(destRoot, { recursive: true });
56
56
 
57
57
  let totalFiles = 0, totalSkipped = 0;
58
- const hashRoot = isGlobal ? globalTarget(platform) : destRoot;
59
58
  for (const bucket of INSTALL_BUCKETS) {
60
59
  const srcDir = join(srcRoot, bucket);
61
60
  const destDir = join(destRoot, bucket);
62
61
  if (!existsSync(srcDir)) continue;
63
- const stats = mergeDir(srcDir, destDir, hashRoot);
62
+ const stats = mergeDir(srcDir, destDir);
64
63
  totalFiles += stats.files;
65
64
  totalSkipped += stats.skipped;
66
65
  const tag = existsSync(destDir) && stats.files > 0 ? '~' : '+';
@@ -214,16 +213,16 @@ function fileHash(filePath) {
214
213
  catch { return null; }
215
214
  }
216
215
 
217
- /** 加载/保存文件 hash 记录 */
218
- function loadHashes(root) {
219
- const f = join(root, '.jarvis', 'file-hashes.json');
216
+ /** 加载/保存文件 hash 记录,统一存储在 ~/.jarvis/file-hashes.json */
217
+ function loadHashes() {
218
+ const f = resolve(homedir(), '.jarvis', 'file-hashes.json');
220
219
  try { return existsSync(f) ? JSON.parse(readFileSync(f, 'utf-8')) : {}; }
221
220
  catch { return {}; }
222
221
  }
223
- function saveHashes(root, hashes) {
224
- const dir = join(root, '.jarvis');
222
+ function saveHashes(hashes) {
223
+ const dir = resolve(homedir(), '.jarvis');
225
224
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
226
- writeFileSync(join(dir, 'file-hashes.json'), JSON.stringify(hashes, null, 2));
225
+ writeFileSync(resolve(dir, 'file-hashes.json'), JSON.stringify(hashes, null, 2));
227
226
  }
228
227
 
229
228
  /**
@@ -233,30 +232,29 @@ function saveHashes(root, hashes) {
233
232
  * - 目标 hash == 旧源 hash → 用户未修改,安全覆盖
234
233
  * - 目标 hash != 旧源 hash → 用户已修改,跳过
235
234
  */
236
- function mergeDir(src, dest, root) {
235
+ function mergeDir(src, dest) {
237
236
  let files = 0, dirs = 0, skipped = 0;
238
237
  if (!existsSync(dest)) mkdirSync(dest, { recursive: true });
239
238
 
240
- const hashes = root ? loadHashes(root) : {};
239
+ const hashes = loadHashes();
241
240
 
242
241
  for (const entry of readdirSync(src)) {
243
242
  if (SKIP_FILES.has(entry)) continue;
244
243
  if (entry.startsWith('.') || entry === 'node_modules') continue;
245
244
  const sp = join(src, entry), dp = join(dest, entry);
246
245
  if (statSync(sp).isDirectory()) {
247
- const d = mergeDir(sp, dp, root);
246
+ const d = mergeDir(sp, dp);
248
247
  files += d.files; dirs += d.dirs + 1; skipped += d.skipped;
249
248
  } else {
250
- const relPath = dp.replace(dest, '').replace(/\\/g, '/');
251
249
  const newHash = fileHash(sp);
252
250
 
253
251
  if (!existsSync(dp)) {
254
252
  // 新文件
255
253
  copyFileSync(sp, dp);
256
- hashes[relPath] = newHash;
254
+ hashes[dp] = newHash;
257
255
  files++;
258
256
  } else {
259
- const oldHash = hashes[relPath];
257
+ const oldHash = hashes[dp];
260
258
  const destHash = fileHash(dp);
261
259
 
262
260
  if (newHash === oldHash) {
@@ -265,7 +263,7 @@ function mergeDir(src, dest, root) {
265
263
  } else if (!oldHash || destHash === oldHash) {
266
264
  // 新安装或用户未修改 → 安全覆盖
267
265
  copyFileSync(sp, dp);
268
- hashes[relPath] = newHash;
266
+ hashes[dp] = newHash;
269
267
  files++;
270
268
  } else {
271
269
  // 用户已修改目标文件 → 保留
@@ -275,7 +273,7 @@ function mergeDir(src, dest, root) {
275
273
  }
276
274
  }
277
275
 
278
- if (root) saveHashes(root, hashes);
276
+ saveHashes(hashes);
279
277
  return { files, dirs, skipped };
280
278
  }
281
279
 
package/src/web/routes.js CHANGED
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { readdirSync, existsSync } from 'node:fs';
4
4
  import { streamSSE } from 'hono/streaming';
5
- import { getPipeline, getCheckpoints, addCheckpoint, updatePipelineGate, getSessions, getAllPipelines, getAgentConfig, setAgentModel, resumeSession, markStaleSessions } from '../engine/db.js';
5
+ import { getPipeline, getCheckpoints, addCheckpoint, updatePipelineGate, getSessions, getAllPipelines, getAgentConfig, setAgentModel, resumeSession, markStaleSessions, getSessionRuns } from '../engine/db.js';
6
6
  import { GATES, GATE_CHECKS, GATE_DIRS, AGENT_LIST, AVAILABLE_MODELS, findGateArtifacts, formatGateDisplay, getPipelineGates, getPipelineName, DEFAULT_PIPELINE } from '../engine/gates.js';
7
7
  import { getAgentList, getPlatformModels, getCategories, getAgentsByPlatform, getPlatforms } from '../engine/agent-registry.js';
8
8
  import { syncAgentFile } from '../engine/agent-fs.js';
@@ -58,7 +58,7 @@ export function setupApiRoutes(app, db, root) {
58
58
 
59
59
  // 引擎状态 + MCP 平台接入信息
60
60
  app.get('/api/status', (c) => {
61
- markStaleSessions(db, 600_000);
61
+ markStaleSessions(db, 1_800_000); // 30min 超时,与 server.js SESSION_TIMEOUT 一致
62
62
  const sessions = getSessions(db, 'active');
63
63
  const connectedPlatforms = {};
64
64
  for (const p of ['claude', 'opencode', 'codex']) {
@@ -109,7 +109,8 @@ export function setupApiRoutes(app, db, root) {
109
109
  app.get('/api/gate/:gate/enforce', (c) => {
110
110
  const gate = c.req.param('gate').replace(/_/g, ' ');
111
111
  const artifacts = findGateArtifacts(getDocsDir(root), gate);
112
- const sid = c.req.query('session_id') || (getSessions(db)[0]?.id);
112
+ const sid = c.req.query('session_id');
113
+ if (!sid) return c.json({ error: 'session_id query parameter required' }, 400);
113
114
  const checkpoints = getCheckpoints(db, gate, sid);
114
115
  const allowed = artifacts.length > 0 || checkpoints.length > 0;
115
116
  return c.json({
@@ -124,7 +125,8 @@ export function setupApiRoutes(app, db, root) {
124
125
 
125
126
  app.post('/api/gate/advance', async (c) => {
126
127
  const body = await c.req.json();
127
- const sid = body.session_id || (getSessions(db)[0]?.id);
128
+ const sid = body.session_id;
129
+ if (!sid) return c.json({ error: 'session_id required in request body' }, 400);
128
130
  const pstate = getPipeline(db, sid);
129
131
  const pt = pstate?.pipeline_type || DEFAULT_PIPELINE;
130
132
  const gateList = getPipelineGates(pt);
@@ -153,7 +155,7 @@ export function setupApiRoutes(app, db, root) {
153
155
  });
154
156
 
155
157
  app.get('/api/sessions', (c) => {
156
- markStaleSessions(db, 600_000);
158
+ markStaleSessions(db, 1_800_000); // 30min 超时,与 server.js SESSION_TIMEOUT 一致
157
159
  const sessions = getSessions(db).map(s => {
158
160
  const p = getPipeline(db, s.id);
159
161
  return {
@@ -179,6 +181,14 @@ export function setupApiRoutes(app, db, root) {
179
181
  return c.json({ ok: true, session_id: sid, status: 'active', gate: p?.current_gate || '?' });
180
182
  });
181
183
 
184
+ // Session Model B: 查询 session 的所有 pipeline runs
185
+ app.get('/api/pipeline-runs', (c) => {
186
+ const sessionId = c.req.query('session_id');
187
+ if (!sessionId) return c.json({ error: 'session_id query parameter required' }, 400);
188
+ const runs = getSessionRuns(db, sessionId);
189
+ return c.json({ runs, count: runs.length, session_id: sessionId });
190
+ });
191
+
182
192
  const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
183
193
 
184
194
  app.get('/api/agents', (c) => {
@@ -144,6 +144,26 @@
144
144
  </div>
145
145
  </div>
146
146
 
147
+ <!-- 历史 Runs 面板 -->
148
+ <div class="bg-white rounded-xl border border-slate-200 shadow-sm mb-8 overflow-hidden">
149
+ <div class="flex items-center justify-between px-6 py-4 cursor-pointer hover:bg-slate-50 transition-colors" onclick="toggleRunsPanel()">
150
+ <div class="flex items-center gap-3">
151
+ <i data-lucide="history" class="w-4 h-4 text-indigo-500"></i>
152
+ <h3 class="text-sm font-semibold text-slate-700">历史运行记录</h3>
153
+ <span class="text-[11px] text-slate-400 font-mono" id="runsCount">0 次运行</span>
154
+ </div>
155
+ <div class="flex items-center gap-2">
156
+ <span class="text-[10px] text-slate-400" id="runsToggleLabel">展开</span>
157
+ <i data-lucide="chevron-down" class="w-4 h-4 text-slate-400 transition-transform" id="runsToggleIcon"></i>
158
+ </div>
159
+ </div>
160
+ <div id="runsPanel" class="hidden border-t border-slate-100">
161
+ <div id="runsList" class="divide-y divide-slate-100 max-h-96 overflow-y-auto">
162
+ <p class="text-xs text-slate-400 text-center py-8 font-mono">请选择左侧会话查看运行记录</p>
163
+ </div>
164
+ </div>
165
+ </div>
166
+
147
167
  <!-- Gate 步骤列表 -->
148
168
  <div class="bg-white rounded-xl p-6 border border-slate-200 shadow-sm">
149
169
  <div class="flex items-center justify-between mb-1">
@@ -287,6 +307,9 @@ async function refresh() {
287
307
  selectedSession = sessionData.session_id;
288
308
  }
289
309
 
310
+ // 加载选中会话的历史 Runs
311
+ fetchPipelineRuns(selectedSession);
312
+
290
313
  const activeCount = allSessions.filter(s => s.status === 'active').length;
291
314
  document.getElementById('pipelineProject').textContent =
292
315
  '会话隔离模式 · ' + (allSessions.length || 0) + ' 个会话(' + activeCount + ' 活跃)· 每 5 秒自动刷新';
@@ -407,7 +430,7 @@ function renderSessions() {
407
430
  document.getElementById('sessionsList').innerHTML = filtered.map(s => {
408
431
  const isActive = s.id === selectedSession;
409
432
  const isInactive = s.status === 'inactive';
410
- const isOnline = !isInactive && (Date.now() - s.heartbeat) < 120000;
433
+ const isOnline = !isInactive && (Date.now() - s.heartbeat) < 600000; // 10分钟
411
434
 
412
435
  let statusColor, statusLabel;
413
436
  if (isInactive) { statusColor = 'bg-amber-500'; statusLabel = '休眠'; }
@@ -448,8 +471,8 @@ async function resumeSession(sid) {
448
471
  }
449
472
 
450
473
  async function checkGate(gate) {
451
- const sid = selectedSession ? '&session_id=' + selectedSession : '';
452
- const d = await fetchAPI('/api/gate/' + gate.replace(/ /g, '_') + '/enforce' + sid);
474
+ if (!selectedSession) { toast('请先选择一个会话', false); return; }
475
+ const d = await fetchAPI('/api/gate/' + gate.replace(/ /g, '_') + '/enforce?session_id=' + selectedSession);
453
476
  if (!d) return;
454
477
  const info = GATE_INFO[gate] || {};
455
478
  toast(d.allowed
@@ -459,6 +482,7 @@ async function checkGate(gate) {
459
482
  }
460
483
 
461
484
  async function advanceGate(gate) {
485
+ if (!selectedSession) { toast('请先选择一个会话', false); return; }
462
486
  const resp = await fetch('/api/gate/advance', {
463
487
  method: 'POST', headers: { 'Content-Type': 'application/json' },
464
488
  body: JSON.stringify({ gate, session_id: selectedSession })
@@ -486,6 +510,110 @@ function toast(msg, isSuccess) {
486
510
  function showHelp() { document.getElementById('helpModal').classList.remove('hidden'); document.getElementById('helpModal').classList.add('flex'); }
487
511
  function closeHelp() { document.getElementById('helpModal').classList.add('hidden'); document.getElementById('helpModal').classList.remove('flex'); }
488
512
 
513
+ /**
514
+ * 展开/收起历史 Runs 面板
515
+ */
516
+ function toggleRunsPanel() {
517
+ const panel = document.getElementById('runsPanel');
518
+ const icon = document.getElementById('runsToggleIcon');
519
+ const label = document.getElementById('runsToggleLabel');
520
+ if (panel.classList.contains('hidden')) {
521
+ panel.classList.remove('hidden');
522
+ icon.style.transform = 'rotate(180deg)';
523
+ label.textContent = '收起';
524
+ } else {
525
+ panel.classList.add('hidden');
526
+ icon.style.transform = 'rotate(0deg)';
527
+ label.textContent = '展开';
528
+ }
529
+ }
530
+
531
+ /**
532
+ * 获取指定会话的 Pipeline Runs 历史
533
+ * @param {string|null} sessionId - 会话 ID
534
+ */
535
+ async function fetchPipelineRuns(sessionId) {
536
+ if (!sessionId) {
537
+ document.getElementById('runsList').innerHTML = '<p class="text-xs text-slate-400 text-center py-8 font-mono">请选择左侧会话查看运行记录</p>';
538
+ document.getElementById('runsCount').textContent = '0 次运行';
539
+ return;
540
+ }
541
+ try {
542
+ const resp = await fetch('/api/pipeline-runs?session_id=' + encodeURIComponent(sessionId));
543
+ if (!resp.ok) {
544
+ document.getElementById('runsList').innerHTML = '<p class="text-xs text-red-400 text-center py-8 font-mono">加载失败(HTTP ' + resp.status + '),请稍后重试</p>';
545
+ document.getElementById('runsCount').textContent = '---';
546
+ return;
547
+ }
548
+ const data = await resp.json();
549
+ if (!data || !Array.isArray(data.runs)) {
550
+ document.getElementById('runsList').innerHTML = '<p class="text-xs text-red-400 text-center py-8 font-mono">数据格式异常,请刷新页面重试</p>';
551
+ document.getElementById('runsCount').textContent = '---';
552
+ return;
553
+ }
554
+ renderRunsHistory(data);
555
+ } catch (_) {
556
+ // 网络错误或 JSON 解析失败
557
+ document.getElementById('runsList').innerHTML = '<p class="text-xs text-red-400 text-center py-8 font-mono">网络错误,无法加载运行记录</p>';
558
+ document.getElementById('runsCount').textContent = '---';
559
+ }
560
+ }
561
+
562
+ /**
563
+ * 渲染历史 Runs 列表
564
+ * @param {{ runs: Array, count: number }} runsData - API 返回的 runs 数据
565
+ */
566
+ function renderRunsHistory(runsData) {
567
+ const runs = runsData.runs;
568
+ document.getElementById('runsCount').textContent = runs.length + ' 次运行';
569
+
570
+ if (runs.length === 0) {
571
+ document.getElementById('runsList').innerHTML = '<p class="text-xs text-slate-400 text-center py-8 font-mono">暂无运行记录</p>';
572
+ lucide.createIcons();
573
+ return;
574
+ }
575
+
576
+ /** 状态 → 视觉映射表 */
577
+ const STATUS_MAP = {
578
+ active: { dotColor: 'bg-emerald-500', badgeClass: 'bg-emerald-50 text-emerald-700', label: '运行中' },
579
+ completed: { dotColor: 'bg-slate-400', badgeClass: 'bg-slate-100 text-slate-600', label: '已完成' },
580
+ aborted: { dotColor: 'bg-red-500', badgeClass: 'bg-red-50 text-red-700', label: '已终止' },
581
+ };
582
+
583
+ document.getElementById('runsList').innerHTML = runs.map(function(run) {
584
+ const st = STATUS_MAP[run.status] || STATUS_MAP.active;
585
+ const isActive = run.status === 'active';
586
+ const shortRunId = (run.id || '').slice(0, 16) + '...';
587
+ const ptName = PIPELINE_NAMES[run.pipeline_type] || run.pipeline_type || '未知';
588
+ let dateText = '---';
589
+ if (run.started_at) {
590
+ try {
591
+ const d = new Date(run.started_at.replace(' ', 'T'));
592
+ dateText = d.toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
593
+ } catch (_) {
594
+ // 日期解析失败时使用原始字符串,后端 datetime('now') 格式安全
595
+ dateText = run.started_at.replace(/[<>&"']/g, '');
596
+ }
597
+ }
598
+
599
+ return (
600
+ '<div class="flex items-center gap-4 px-6 py-3 hover:bg-slate-50/50 transition-colors ' +
601
+ (isActive ? 'bg-indigo-50/30 border-l-[3px] border-indigo-500' : 'border-l-[3px] border-transparent') + '">' +
602
+ '<div class="flex items-center gap-2 min-w-0">' +
603
+ '<span class="w-2 h-2 rounded-full flex-shrink-0 ' + st.dotColor + '"></span>' +
604
+ '<span class="text-xs font-mono text-slate-600 truncate" title="' + run.id + '">' + shortRunId + '</span>' +
605
+ '</div>' +
606
+ '<span class="text-[10px] font-medium bg-indigo-50 text-indigo-600 px-2 py-0.5 rounded flex-shrink-0">' + ptName + '</span>' +
607
+ '<span class="text-[11px] text-slate-500 flex-shrink-0 font-mono">' + (run.current_gate || '---') + '</span>' +
608
+ '<span class="text-[10px] text-slate-400 flex-shrink-0 ml-auto font-mono">' + dateText + '</span>' +
609
+ '<span class="text-[10px] font-medium px-2 py-0.5 rounded-full flex-shrink-0 ' + st.badgeClass + '">' + st.label + '</span>' +
610
+ '</div>'
611
+ );
612
+ }).join('');
613
+
614
+ lucide.createIcons();
615
+ }
616
+
489
617
  // MCP 平台接入状态轮询
490
618
  async function loadMcpStatus(){
491
619
  try{