xitto-kernel 0.3.2 → 0.3.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.5
4
+
5
+ - **執行中沉澱經驗 — 專案手冊(程序層)**:agent 摸清專案「做事方法」時自己記下來,跨 session 自動載入。
6
+ - 新增 kernel 內建工具 `playbook_update`(按 topic 記/更新,同 topic 覆蓋去重)、`playbook_remove`
7
+ - 落地 `.xitto-kernel/<pack>/playbook.md`;綁 cwd → 天然只對該專案生效(自帶相關性範圍)
8
+ - 開場自動注入 system prompt(`# 專案手冊`)+ 引導語;與 `memory`(事實層) 分工明確
9
+ - `/playbook`(查看)、`/playbook forget <主題>`、`/playbook clear`;`api.playbook.{list,update,remove,clear,load,path}`
10
+ - 5 個測試(topic 去重/多條/落地重載/多行 note/kernel 注入)+ 真實 model 端到端閉環驗證
11
+ (agent 呼叫 playbook_update → 落地 → 新 session 自動載入)
12
+
13
+ ## 0.3.4
14
+
15
+ - **漸進式放權(per-pattern 記住批准)**:把「事事都問的煩」和「全自動的怕」同時解掉。
16
+ - 批准工具時可選 `[a]` 信任整個工具、或 `[c]` 只信任「命令簽章類」(`git status` ≠ `npm install`)
17
+ - 信任**落地到 `.xitto-kernel/<pack>/allow.json`,跨 session 累積**;重啟後同類自動放行
18
+ - 自動放行時標示「✓ 已信任」(`onTrusted` 回呼,維持可理解性)
19
+ - **危險命令永不寫入信任**,即使選 always 也只放行這次
20
+ - `/trust`(查看)、`/trust forget <項>`、`/trust clear`;`api.permissions.{list,forget,clear,path}`
21
+ - 新增 `allow-store.js`(`memoryAllowStore` / `fileAllowStore`);接通既有 `parseAllowFile`/`commandSignature`
22
+ - 6 個測試 + 跨 kernel 實例(模擬重啟)整合驗證
23
+
24
+ ## 0.3.3
25
+
26
+ - **背景任務 + 完成通知(非同步交互)**:server 新增「派任務→通知」形態,把 agent 當同事用。
27
+ - `POST /v1/tasks`:立刻回 `202 + taskId`,後台跑,完成 POST 結果到 `webhook`
28
+ - `GET /v1/tasks`、`GET /v1/tasks/:id`:列表 / 狀態 + 結果
29
+ - `GET /v1/tasks/:id/events`:附掛事件流(SSE,replay 緩衝 + 即時)
30
+ - 限流並發 `XITTO_SERVER_CONCURRENCY`(預設 2)
31
+ - 抽出 `createTaskStore`(純記憶體、可測)與 `mapEvent`;`/v1/run`/`/v1/stream` 共用 `runKernel`
32
+ - 5 個任務佇列測試(狀態轉移 / 限流 / 事件緩衝 / 完成回呼 / 訂閱)
33
+
3
34
  ## 0.3.2
4
35
 
5
36
  - **沒設定就啟動 → 直接進導引**:偵測到沒有 providers.json 且在真實終端時,
package/README.md CHANGED
@@ -48,7 +48,11 @@ xitto-kernel --pack data-query
48
48
  xitto-kernel --sandbox # 啟動就開 Seatbelt 沙箱
49
49
  ```
50
50
 
51
- **CLI 內操作**:直接打需求(模型會自己呼叫工具);指令 `/help` `/goal <目標>` `/sandbox` `/plan` `/undo` `/tools` `/memory` `/sessions` `/resume` `/exit`;`Ctrl+C` 中斷該輪、閒置時再按一次離開。
51
+ **CLI 內操作**:直接打需求(模型會自己呼叫工具);指令 `/help` `/goal <目標>` `/sandbox` `/plan` `/undo` `/tools` `/trust` `/memory` `/sessions` `/resume` `/exit`;`Ctrl+C` 中斷該輪、閒置時再按一次離開。
52
+
53
+ **漸進式放權(trust 隨用累積)**:mutating/危險工具執行前會確認;批准時可選 `[a]` 信任整個工具、或 `[c]` 只信任「該命令簽章類」(如 `git status`、`npm test`——細粒度,`npm install` 仍會問)。選擇會**落地到 `.xitto-kernel/<pack>/allow.json`,跨 session 記得**,下次同類自動放行並標示「✓ 已信任」。`/trust` 查看、`/trust forget <項>` 撤銷、`/trust clear` 全清。一開始謹慎、用著用著越來越順手——危險命令永不寫入信任,每次都把關。
54
+
55
+ **執行中沉澱經驗(專案手冊)**:agent 摸清「這個專案怎麼做事」(建置/測試/部署指令、慣例、必經步驟、踩過的坑與修法)時,會用 `playbook_update` 按 topic 記進 `.xitto-kernel/<pack>/playbook.md`(同 topic 覆蓋,天然去重);**下次 session 自動載入系統提示,不必重新摸索**。因檔案綁 cwd,手冊天然只對這個專案生效。`/playbook` 查看、`/playbook forget <主題>`、`/playbook clear`。分工:`memory` 存事實/偏好/決策(扁平),`playbook` 存可重複的程序知識(按主題)。
52
56
 
53
57
  **通用自主 agent(給目標、自己做到完成)**
54
58
  ```bash
@@ -72,6 +76,20 @@ curl -s -XPOST localhost:8787/v1/run -H "Authorization: Bearer secret" \
72
76
  結構化 JSON 日誌(審計/觀測)、6 個 pack 可選、JSON 或 SSE(`/v1/stream`)串流。
73
77
  「個人 vs 生產」是 **app 層**的事 —— 同一個 kernel,CLI 與 server 是兩個 app。
74
78
 
79
+ **背景任務 + 完成通知(非同步交互)** —— 派任務出去、立刻拿到 `taskId`、做完回呼 webhook,不用一直盯著:
80
+ ```bash
81
+ # 派任務(立刻回 202 + taskId),完成時 POST 結果到 webhook
82
+ curl -s -XPOST localhost:8787/v1/tasks -H "Authorization: Bearer secret" \
83
+ -H content-type:application/json \
84
+ -d '{"pack":"general","mode":"goal","goal":"...","webhook":"https://你的服務/done"}'
85
+
86
+ curl -s localhost:8787/v1/tasks -H "Authorization: Bearer secret" # 列表
87
+ curl -s localhost:8787/v1/tasks/<id> -H "Authorization: Bearer secret" # 狀態 + 結果
88
+ curl -sN localhost:8787/v1/tasks/<id>/events -H "Authorization: Bearer secret" # 附掛事件流(SSE,replay+即時)
89
+ ```
90
+ 限流並發 `XITTO_SERVER_CONCURRENCY`(預設 2);webhook 完成時收到 `{taskId,status,text,usage,rounds,done}`。
91
+ 這把「即時盯著看」延伸到「派任務→通知」的非同步形態(像把 agent 當同事)。
92
+
75
93
  ## 做你自己的領域 agent(不固化)
76
94
 
77
95
  kernel 是**被依賴的套件**,不是被 clone 的範本。你的 agent 是獨立小專案:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.3.2",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
package/src/app/cli.js CHANGED
@@ -36,6 +36,10 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
36
36
  getSandbox: () => sandboxOn, // on/off 由 CLI 即時切換
37
37
  getPlanMode: () => planMode, // 計劃模式:守衛擋 mutating 工具
38
38
  confirm: askConfirm, // 互動權限確認(mutating/危險工具執行前)
39
+ onTrusted: ({ name, signature, scope }) => { // 漸進放權:自動放行時標示「已信任」(維持可理解)
40
+ endStream();
41
+ out(c.gray(` ✓ 已信任 ${scope === 'command' ? `「${signature}」類` : name},自動放行\n`));
42
+ },
39
43
  });
40
44
 
41
45
  let history = [];
@@ -63,19 +67,25 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
63
67
  };
64
68
 
65
69
  // 互動權限確認:守衛鏈第 5 格對 mutating/危險工具呼叫此函數。autoApprove → 一律放行。
66
- // 回 'yes'(允許一次)/ 'always'(此工具全部)/ 'no'(拒絕)。
67
- async function askConfirm(name, args, danger) {
70
+ // 回 'yes'(允許一次)/ 'command'(信任此命令簽章類,跨 session)/ 'always'(信任此工具全部)/ 'no'(拒絕)。
71
+ async function askConfirm(name, args, danger, meta = {}) {
68
72
  if (autoApprove && !danger) return 'yes'; // 自動模式仍對危險命令把關
69
73
  endStream();
74
+ const sig = meta.signature; // 有簽章(bash 類)才提供細粒度「信任這類命令」
70
75
  return new Promise((res) => {
71
76
  const warn = danger ? c.red(` ⛔ 危險:${danger}\n`) : '';
72
77
  out(warn + c.yellow(' 需要許可 ') + c.bold(name) + c.gray('(' + summarize(args) + ')') + '\n');
73
- const hint = danger ? '[y]允許一次 [n]拒絕' : '[y]允許 [a]此工具全部 [n]拒絕';
78
+ const hint = danger ? '[y]允許一次 [n]拒絕'
79
+ : sig ? `[y]允許 [c]信任「${sig}」類 [a]信任 ${name} 全部 [n]拒絕`
80
+ : `[y]允許 [a]信任 ${name} 全部 [n]拒絕`;
81
+ out(c.gray(' (c/a 會記住,下次自動放行;/trust 查看與撤銷)\n'));
74
82
  try {
75
83
  rl.question(c.yellow(` ${hint} › `), (ans) => {
76
84
  const a = (ans || '').trim().toLowerCase();
77
85
  if (danger) return res(a === 'y' || a === 'yes' ? 'yes' : 'no');
78
- res(a === 'y' || a === 'yes' ? 'yes' : a === 'a' ? 'always' : 'no');
86
+ if (a === 'a') return res('always');
87
+ if (a === 'c' && sig) return res('command');
88
+ res(a === 'y' || a === 'yes' ? 'yes' : 'no');
79
89
  });
80
90
  } catch { res('no'); }
81
91
  });
@@ -163,7 +173,9 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
163
173
  ' /goal <目標> 目標驅動自主循環(反覆做到完成)',
164
174
  ' /undo 撤銷上一次檔案改動(write/edit)',
165
175
  ' /tools 列出此 pack 的工具',
176
+ ' /trust [forget <項>|clear] 已信任的工具/命令(漸進放權,跨 session)',
166
177
  ' /memory 顯示跨 session 記憶',
178
+ ' /playbook [forget <主題>|clear] 專案手冊(agent 沉澱的程序知識,跨 session)',
167
179
  ' /sessions 列出已保存的對話',
168
180
  ' /resume [id] 續接對話(不給 id=最近一次)',
169
181
  ' /clear 清除歷史(開新 session)',
@@ -175,6 +187,36 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
175
187
  out(mems.length ? c.gray(mems.map((m) => ' • ' + m).join('\n') + '\n') : c.gray('(尚無記憶)\n'));
176
188
  return true;
177
189
  }
190
+ case '/playbook': {
191
+ const rest = input.trim().slice(cmd.length).trim();
192
+ if (rest === 'clear') { const { cleared } = kernel.playbook.clear(); out(c.gray(`(已清空專案手冊,移除 ${cleared} 條)\n`)); return true; }
193
+ if (rest.startsWith('forget ')) {
194
+ const t = rest.slice('forget '.length).trim();
195
+ const r = kernel.playbook.remove(t);
196
+ out(r.removed ? c.gray(`(已移除「${t}」)\n`) : c.yellow(`找不到主題「${t}」\n`));
197
+ return true;
198
+ }
199
+ const entries = kernel.playbook.list();
200
+ if (!entries.length) { out(c.gray('(尚無專案手冊;agent 摸清做法時會用 playbook_update 累積)\n')); return true; }
201
+ out(entries.map((e) => c.cyan(` ## ${e.topic}\n`) + c.gray(e.note.split('\n').map((l) => ' ' + l).join('\n'))).join('\n') + '\n');
202
+ if (kernel.playbook.path) out(c.gray(` ↳ ${kernel.playbook.path}(清除:/playbook forget <主題>)\n`));
203
+ return true;
204
+ }
205
+ case '/trust': {
206
+ const rest = input.trim().slice(cmd.length).trim();
207
+ if (rest === 'clear') { kernel.permissions.clear(); out(c.gray('(已清除全部信任)\n')); return true; }
208
+ if (rest.startsWith('forget ')) {
209
+ const entry = rest.slice('forget '.length).trim();
210
+ out(kernel.permissions.forget(entry) ? c.gray(`(已撤銷信任:${entry})\n`) : c.yellow(`找不到信任項「${entry}」\n`));
211
+ return true;
212
+ }
213
+ const { tools, bash } = kernel.permissions.list();
214
+ if (!tools.length && !bash.length) { out(c.gray('(尚無已信任項;批准工具時選 a/c 即可記住)\n')); return true; }
215
+ if (tools.length) out(c.gray(' 工具(全部放行):') + tools.join('、') + '\n');
216
+ if (bash.length) out(c.gray(' 命令(簽章類):') + bash.map((s) => `「${s}」`).join('、') + '\n');
217
+ if (kernel.permissions.path) out(c.gray(` ↳ ${kernel.permissions.path}(撤銷:/trust forget <項>)\n`));
218
+ return true;
219
+ }
178
220
  case '/sessions': {
179
221
  const ss = kernel.session.list();
180
222
  out(ss.length
@@ -225,7 +267,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
225
267
  };
226
268
 
227
269
  // 斜線指令 tab 補全
228
- const SLASH = ['/help', '/goal ', '/sandbox', '/auto', '/plan', '/undo', '/tools', '/memory', '/sessions', '/resume', '/clear', '/exit'];
270
+ const SLASH = ['/help', '/goal ', '/sandbox', '/auto', '/plan', '/undo', '/tools', '/trust', '/memory', '/playbook', '/sessions', '/resume', '/clear', '/exit'];
229
271
  const completer = (line) => {
230
272
  if (!line.startsWith('/')) return [[], line];
231
273
  const hits = SLASH.filter((s) => s.startsWith(line));
package/src/app/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Server app(PoC)— 把 kernel 包成 HTTP 服務(零依賴 node:http)。
2
2
  // 證明 kernel 能脫離 CLI 跑成服務:bearer token 認證、per-session 隔離工作目錄、沙箱、結構化日誌、
3
- // JSON 或 SSE 串流。這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
3
+ // JSON 或 SSE 串流,以及「背景任務 + 完成通知(webhook)」—— 派任務出去、做完回呼,不用一直盯著。
4
+ // 這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
4
5
  import { createServer } from 'node:http';
5
6
  import { mkdirSync } from 'node:fs';
6
7
  import { join } from 'node:path';
@@ -20,7 +21,75 @@ const PACKS = {
20
21
  };
21
22
 
22
23
  const lastText = (history) => ([...(history || [])].reverse().find((m) => m.role === 'assistant')?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
23
- const newId = () => 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
24
+ const newId = (p = 's') => p + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
25
+
26
+ // 把原始 kernel 事件壓成精簡的對外事件(串流端與背景任務共用,避免重複映射)
27
+ export const mapEvent = (ev) => {
28
+ if (ev.type === 'tool_execution_start') return { type: 'tool', name: ev.toolName, args: ev.args };
29
+ if (ev.type === 'tool_execution_end') return { type: 'tool_end', name: ev.toolName, isError: !!ev.isError };
30
+ if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') return { type: 'text', delta: ev.assistantMessageEvent.delta };
31
+ return null;
32
+ };
33
+
34
+ /**
35
+ * 背景任務佇列(純記憶體、可測,與 HTTP 無關)。
36
+ * 派任務 → 限流跑 → 緩衝事件供事後附掛 → 完成回呼(webhook)。
37
+ * @param {Object} o
38
+ * @param {(spec:object, emit:(ev:object)=>void)=>Promise<any>} o.runJob 實際執行(回傳值=任務結果)
39
+ * @param {number} [o.concurrency] 同時跑幾個(預設 2)
40
+ * @param {(task:object)=>void} [o.onFinish] 每個任務 settle 後呼叫(拿來發 webhook)
41
+ * @param {number} [o.maxEvents] 每任務保留最近幾筆事件(預設 500)
42
+ */
43
+ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents = 500 } = {}) {
44
+ const tasks = new Map(); // id -> task
45
+ const queue = []; // 等待中的 task
46
+ const subs = new Map(); // id -> Set<(ev)=>void>
47
+ let active = 0;
48
+
49
+ const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', sessionId: t.result?.sessionId || t.spec.sessionId || null, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error });
50
+
51
+ const emit = (t, ev) => {
52
+ t.events.push(ev);
53
+ if (t.events.length > maxEvents) t.events.shift();
54
+ const s = subs.get(t.id); if (s) for (const fn of s) { try { fn(ev); } catch { /* 訂閱端錯不影響任務 */ } }
55
+ };
56
+
57
+ function pump() {
58
+ while (active < concurrency && queue.length) {
59
+ const t = queue.shift();
60
+ active++;
61
+ t.status = 'running'; t.startedAt = new Date().toISOString();
62
+ emit(t, { type: 'status', status: 'running' });
63
+ Promise.resolve()
64
+ .then(() => runJob(t.spec, (ev) => emit(t, ev)))
65
+ .then((result) => { t.status = 'done'; t.result = result; })
66
+ .catch((e) => { t.status = 'error'; t.error = e.message || String(e); })
67
+ .finally(() => {
68
+ t.finishedAt = new Date().toISOString();
69
+ emit(t, { type: 'end', status: t.status, result: t.result, error: t.error });
70
+ active--;
71
+ try { onFinish?.(t); } catch { /* webhook 錯不影響佇列 */ }
72
+ pump();
73
+ });
74
+ }
75
+ }
76
+
77
+ return {
78
+ enqueue(spec) {
79
+ const t = { id: newId('t'), status: 'queued', spec: spec || {}, events: [], result: null, error: null, createdAt: new Date().toISOString(), startedAt: null, finishedAt: null };
80
+ tasks.set(t.id, t);
81
+ queue.push(t);
82
+ pump();
83
+ return t;
84
+ },
85
+ get: (id) => tasks.get(id),
86
+ view: (id) => { const t = tasks.get(id); return t ? view(t) : null; },
87
+ result: (id) => { const t = tasks.get(id); return t ? { ...view(t), result: t.result } : null; },
88
+ list: () => [...tasks.values()].map(view),
89
+ subscribe(id, fn) { let s = subs.get(id); if (!s) { s = new Set(); subs.set(id, s); } s.add(fn); return () => s.delete(fn); },
90
+ stats: () => ({ active, queued: queue.length, total: tasks.size }),
91
+ };
92
+ }
24
93
 
25
94
  /**
26
95
  * @param {Object} o
@@ -29,9 +98,10 @@ const newId = () => 's' + Date.now().toString(36) + Math.random().toString(36).s
29
98
  * @param {string} [o.token] bearer token(未設=不驗證,僅 PoC)
30
99
  * @param {string} [o.baseDir] 每個 session 的隔離工作目錄根
31
100
  * @param {boolean} [o.sandbox] 是否沙箱(預設 true:服務端跑 agent 應隔離)
101
+ * @param {number} [o.concurrency] 背景任務同時數(預設 2)
32
102
  * @returns {import('node:http').Server}
33
103
  */
34
- export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true } = {}) {
104
+ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2 } = {}) {
35
105
  const sessions = new Map(); // sessionId -> { pack, history }
36
106
  mkdirSync(baseDir, { recursive: true });
37
107
 
@@ -39,49 +109,93 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
39
109
  const authed = (req) => !token || (req.headers.authorization === `Bearer ${token}`);
40
110
  const log = (o) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...o }));
41
111
  const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; if (b.length > 1e6) req.destroy(); }); req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } }); });
112
+ const sseHead = (res) => res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
113
+
114
+ // 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件
115
+ async function runKernel(spec, onEvent) {
116
+ const make = PACKS[spec.pack || 'general'];
117
+ if (!make) throw new Error(`未知 pack「${spec.pack}」,可用:${Object.keys(PACKS).join(', ')}`);
118
+ const sessionId = spec.sessionId || newId();
119
+ const sess = sessions.get(sessionId) || { pack: spec.pack || 'general', history: [] };
120
+ const workdir = join(baseDir, sessionId); mkdirSync(workdir, { recursive: true });
121
+ const kernel = createKernel(make({ cwd: workdir }), { cwd: workdir, model, getApiKey, sandbox: { enabled: sandbox }, getSandbox: () => sandbox, confirm: async () => 'yes' });
122
+ const usage = { input: 0, output: 0 };
123
+ const wrapped = (ev) => { if (ev.type === 'message_end' && ev.message?.usage) { usage.input += ev.message.usage.input || 0; usage.output += ev.message.usage.output || 0; } onEvent?.(ev); };
124
+ const r = (spec.mode === 'goal')
125
+ ? await kernel.runGoal(spec.goal || spec.input || '', { history: sess.history, onEvent: wrapped })
126
+ : await kernel.runTurn(spec.input || '', { history: sess.history, onEvent: wrapped });
127
+ sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
128
+ return { sessionId, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
129
+ }
130
+
131
+ // 完成通知:POST 結果到 spec.webhook(http/https),單次嘗試、失敗記日誌不重試(PoC)
132
+ async function fireWebhook(task) {
133
+ const url = task.spec.webhook; if (!url || !/^https?:\/\//.test(url)) return;
134
+ const r = task.result || {};
135
+ const body = JSON.stringify({ taskId: task.id, status: task.status, error: task.error, sessionId: r.sessionId, text: r.text, usage: r.usage, rounds: r.rounds, done: r.done, finishedAt: task.finishedAt });
136
+ try { const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body }); log({ webhook: url, task: task.id, status: task.status, code: resp.status }); }
137
+ catch (e) { log({ webhook: url, task: task.id, error: e.message }); }
138
+ }
139
+
140
+ const tasks = createTaskStore({
141
+ concurrency,
142
+ runJob: (spec, emit) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }),
143
+ onFinish: (task) => { log({ task: task.id, pack: task.spec.pack, mode: task.spec.mode || 'turn', status: task.status, ms: task.startedAt ? Date.parse(task.finishedAt) - Date.parse(task.startedAt) : 0 }); fireWebhook(task); },
144
+ });
42
145
 
43
146
  return createServer(async (req, res) => {
44
147
  const url = new URL(req.url, 'http://localhost');
45
- if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { ok: true, packs: Object.keys(PACKS), model: model.id });
148
+ const path = url.pathname;
149
+ if (req.method === 'GET' && path === '/health') return json(res, 200, { ok: true, packs: Object.keys(PACKS), model: model.id, tasks: tasks.stats() });
46
150
  if (!authed(req)) return json(res, 401, { error: 'unauthorized(帶 Authorization: Bearer <token>)' });
47
151
 
48
- if (req.method === 'POST' && (url.pathname === '/v1/run' || url.pathname === '/v1/stream')) {
152
+ // 同步:跑完才回(JSON SSE 串流)
153
+ if (req.method === 'POST' && (path === '/v1/run' || path === '/v1/stream')) {
49
154
  const body = await readBody(req);
50
- const make = PACKS[body.pack || 'general'];
51
- if (!make) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
52
- const sessionId = body.sessionId || newId();
53
- const sess = sessions.get(sessionId) || { pack: body.pack || 'general', history: [] };
54
- const workdir = join(baseDir, sessionId); mkdirSync(workdir, { recursive: true });
55
- const kernel = createKernel(make({ cwd: workdir }), { cwd: workdir, model, getApiKey, sandbox: { enabled: sandbox }, getSandbox: () => sandbox, confirm: async () => 'yes' });
56
-
57
- const usage = { input: 0, output: 0 };
58
- const streaming = url.pathname === '/v1/stream';
59
- if (streaming) res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
155
+ const streaming = path === '/v1/stream';
156
+ if (streaming) sseHead(res);
60
157
  const sse = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
61
- const onEvent = (ev) => {
62
- if (ev.type === 'message_end' && ev.message?.usage) { usage.input += ev.message.usage.input || 0; usage.output += ev.message.usage.output || 0; }
63
- if (!streaming) return;
64
- if (ev.type === 'tool_execution_start') sse({ type: 'tool', name: ev.toolName, args: ev.args });
65
- else if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') sse({ type: 'text', delta: ev.assistantMessageEvent.delta });
66
- };
67
-
68
158
  const t0 = Date.now();
69
159
  try {
70
- const r = (body.mode === 'goal')
71
- ? await kernel.runGoal(body.goal || body.input || '', { history: sess.history, onEvent })
72
- : await kernel.runTurn(body.input || '', { history: sess.history, onEvent });
73
- sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
74
- const text = r.text ?? lastText(sess.history);
75
- log({ pack: sess.pack, session: sessionId, mode: body.mode || 'turn', tokens: usage.input + usage.output, rounds: r.rounds, ms: Date.now() - t0 });
76
- const payload = { sessionId, text, usage, rounds: r.rounds, done: r.done };
77
- if (streaming) { sse({ type: 'done', ...payload }); res.end(); }
78
- else json(res, 200, payload);
160
+ const r = await runKernel(body, streaming ? (ev) => { const m = mapEvent(ev); if (m) sse(m); } : undefined);
161
+ log({ pack: body.pack || 'general', session: r.sessionId, mode: body.mode || 'turn', tokens: r.usage.input + r.usage.output, rounds: r.rounds, ms: Date.now() - t0 });
162
+ if (streaming) { sse({ type: 'done', ...r }); res.end(); } else json(res, 200, r);
79
163
  } catch (e) {
80
- log({ pack: sess.pack, session: sessionId, error: e.message });
81
- if (streaming) { sse({ type: 'error', error: e.message }); res.end(); } else json(res, 500, { error: e.message });
164
+ log({ pack: body.pack, error: e.message });
165
+ if (streaming) { sse({ type: 'error', error: e.message }); res.end(); } else json(res, e.message?.startsWith('未知 pack') ? 400 : 500, { error: e.message });
82
166
  }
83
167
  return;
84
168
  }
169
+
170
+ // 背景任務:立刻回 taskId,後台跑,完成發 webhook
171
+ if (req.method === 'POST' && path === '/v1/tasks') {
172
+ const body = await readBody(req);
173
+ if (!PACKS[body.pack || 'general']) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
174
+ if (body.webhook && !/^https?:\/\//.test(body.webhook)) return json(res, 400, { error: 'webhook 需為 http(s) URL' });
175
+ const t = tasks.enqueue({ pack: body.pack, mode: body.mode, input: body.input, goal: body.goal, sessionId: body.sessionId, webhook: body.webhook });
176
+ log({ task: t.id, action: 'enqueue', pack: body.pack || 'general', mode: body.mode || 'turn' });
177
+ return json(res, 202, { taskId: t.id, status: t.status, ...tasks.stats() });
178
+ }
179
+ if (req.method === 'GET' && path === '/v1/tasks') return json(res, 200, { tasks: tasks.list(), ...tasks.stats() });
180
+
181
+ // 任務狀態 / 結果
182
+ const mTask = path.match(/^\/v1\/tasks\/([^/]+)$/);
183
+ if (req.method === 'GET' && mTask) { const v = tasks.result(mTask[1]); return v ? json(res, 200, v) : json(res, 404, { error: 'task not found' }); }
184
+
185
+ // 附掛背景任務的事件流(replay 緩衝 + 即時;已結束則回放後關閉)
186
+ const mEv = path.match(/^\/v1\/tasks\/([^/]+)\/events$/);
187
+ if (req.method === 'GET' && mEv) {
188
+ const task = tasks.get(mEv[1]);
189
+ if (!task) return json(res, 404, { error: 'task not found' });
190
+ sseHead(res);
191
+ const sse = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
192
+ for (const ev of task.events) sse(ev);
193
+ if (task.status === 'done' || task.status === 'error') { res.end(); return; }
194
+ const unsub = tasks.subscribe(task.id, (ev) => { sse(ev); if (ev.type === 'end') { try { unsub(); } catch { /* 略 */ } res.end(); } });
195
+ req.on('close', () => { try { unsub(); } catch { /* 略 */ } });
196
+ return;
197
+ }
198
+
85
199
  json(res, 404, { error: 'not found' });
86
200
  });
87
201
  }
@@ -90,11 +204,13 @@ export function startServer() {
90
204
  const port = Number(process.env.PORT || 8787);
91
205
  const token = process.env.XITTO_SERVER_TOKEN || 'dev-token';
92
206
  const sandbox = process.env.XITTO_SERVER_SANDBOX !== 'off';
207
+ const concurrency = Number(process.env.XITTO_SERVER_CONCURRENCY || 2);
93
208
  const { model, getApiKey } = loadModel(process.env.XITTO_MODEL);
94
- const server = createServerApp({ model, getApiKey, token, sandbox });
209
+ const server = createServerApp({ model, getApiKey, token, sandbox, concurrency });
95
210
  server.listen(port, () => {
96
- console.log(`xitto-kernel server · http://localhost:${port} · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'}`);
211
+ console.log(`xitto-kernel server · http://localhost:${port} · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}`);
97
212
  console.log(`token: ${token === 'dev-token' ? 'dev-token(請設 XITTO_SERVER_TOKEN)' : '(已設定)'}`);
213
+ console.log('路由:POST /v1/run · /v1/stream · /v1/tasks(背景+webhook)|GET /v1/tasks[/:id[/events]] · /health');
98
214
  });
99
215
  return server;
100
216
  }
@@ -8,8 +8,10 @@ import { loadPack } from './pack-loader.js';
8
8
  import { createToolRegistry, deriveMutatingTools, isSandboxable } from './tool-registry.js';
9
9
  import { composeGuards } from './guard-chain.js';
10
10
  import { createPermissionStep } from './security/permission-step.js';
11
+ import { fileAllowStore, memoryAllowStore } from './security/allow-store.js';
11
12
  import { normalizeSandbox, wrapWithSeatbelt } from './security/sandbox.js';
12
13
  import { createMemory } from './memory.js';
14
+ import { createPlaybook } from './playbook.js';
13
15
  import { createTodo } from './todo.js';
14
16
  import { createSpawnTool } from './subagent.js';
15
17
  import { createSkills } from './skills.js';
@@ -39,7 +41,10 @@ function loadContextFiles(cwd, names) {
39
41
  }
40
42
 
41
43
  const DEFAULT_MEMORY_GUIDE =
42
- '遇到值得跨 session 記住的事實(使用者偏好、建置/測試指令、踩過的坑、專案決策)時,當下就存一條。';
44
+ '遇到值得跨 session 記住的事實(使用者偏好、踩過的坑、專案決策)時,當下就存一條。';
45
+
46
+ const DEFAULT_PLAYBOOK_GUIDE =
47
+ '摸清這個專案的「做事方法」(如何建置/測試/執行/部署、慣例、必經步驟、坑與修法)時,用 playbook_update 按 topic 記下來(同 topic 覆蓋);過時就用 playbook_remove 清掉。下次自動載入,不必重新摸索。分工:memory 存事實/偏好/決策,playbook 存可重複的程序步驟。';
43
48
 
44
49
  // 把 sandboxable 工具的命令在執行期包進 Seatbelt(macOS OS 級隔離)。
45
50
  // 非 macOS / 沙箱關閉 / 無 command → wrapWithSeatbelt 回 null,跑原命令(仍受第 5 格靜態策略保護)。
@@ -100,6 +105,7 @@ export function createKernel(pack, config = {}) {
100
105
  // 每個 pack 在 cwd 下有獨立資料夾(記憶、session 分領域存放,互不混)
101
106
  const dataDir = join(cwd, '.xitto-kernel', pack.name);
102
107
  const memory = createMemory(join(dataDir, 'memory.md'));
108
+ const playbook = createPlaybook(join(dataDir, 'playbook.md'));
103
109
  const todo = createTodo();
104
110
  const sessionsDir = join(dataDir, 'sessions');
105
111
  const hooks = loadHooks(join(dataDir, 'settings.json')); // PreToolUse/PostToolUse
@@ -115,6 +121,7 @@ export function createKernel(pack, config = {}) {
115
121
  const baseTools = [
116
122
  ...pack.tools().map((t) => wrapUndo(wrapSandboxable(t, { cwd, getSandbox, getSandboxConfig }), { cwd, undoStack })),
117
123
  ...memory.tools,
124
+ ...playbook.tools,
118
125
  todo.tool,
119
126
  ...(skills.tool ? [skills.tool] : []),
120
127
  ...(config.extraTools || []), // 外部注入(MCP 工具等):由 app 層先 async 載入再傳入
@@ -138,20 +145,29 @@ export function createKernel(pack, config = {}) {
138
145
  };
139
146
 
140
147
  const memText = memory.load();
148
+ const pbText = playbook.load();
141
149
  const systemPrompt =
142
150
  pack.systemPrompt +
143
151
  loadContextFiles(cwd, pack.contextFiles) + // 注入領域規範檔(CLAUDE.md 等)
144
- '\n\n# 記憶\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) +
152
+ '\n\n# 記憶與專案手冊\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) + '\n' + DEFAULT_PLAYBOOK_GUIDE +
145
153
  (memText ? `\n\n# 已記住的事實(跨 session)\n${memText}` : '') +
154
+ (pbText ? `\n\n# 專案手冊(這個專案怎麼做事,跨 session 累積)\n${pbText}` : '') +
146
155
  skills.promptSection();
147
156
 
148
157
  const getPlanMode = config.getPlanMode || (() => false);
149
158
 
159
+ // 漸進式放權:已信任的工具/命令簽章跨 session 累積。
160
+ // 預設落地到 .xitto-kernel/<pack>/allow.json;config.allowStore=false → 不持久化(記憶體版);給字串 → 自訂路徑。
161
+ const allowStore = config.allowStore === false ? memoryAllowStore()
162
+ : fileAllowStore(typeof config.allowStore === 'string' ? config.allowStore : join(dataDir, 'allow.json'));
163
+
150
164
  // 守衛鏈第 5 格:真實權限/沙箱(A 半部:靜態策略 deny/網路/提權/越界寫入 + 危險命令)。
151
165
  const permission = createPermissionStep({
152
166
  registry, getSandbox, getSandboxConfig,
153
167
  deny: pack.permissionPolicy?.deny || [],
154
168
  confirm: config.confirm,
169
+ store: allowStore,
170
+ onTrusted: config.onTrusted,
155
171
  });
156
172
 
157
173
  const guard = composeGuards({
@@ -172,8 +188,17 @@ export function createKernel(pack, config = {}) {
172
188
  systemPrompt,
173
189
  services,
174
190
  permissionPolicy: pack.permissionPolicy || {},
191
+ // 已信任清單(漸進放權):列出 / 移除 / 全清;path 為落地檔(記憶體版為 null)。
192
+ permissions: {
193
+ list: () => allowStore.list(),
194
+ forget: (entry) => allowStore.remove(entry),
195
+ clear: () => allowStore.clear(),
196
+ path: allowStore.path,
197
+ },
175
198
  sandbox: { isOn: () => getSandbox(), config: () => getSandboxConfig() },
176
199
  memory,
200
+ // 專案手冊(程序層沉澱):列出 / 更新 / 移除 / 全清;path 為落地檔。
201
+ playbook: { list: playbook.list, update: playbook.update, remove: playbook.remove, clear: playbook.clear, load: playbook.load, path: join(dataDir, 'playbook.md') },
177
202
  todo: { get: todo.get },
178
203
  /** 撤銷上一次檔案改動(write/edit):還原內容,新建的檔則刪除。 */
179
204
  undo: () => {
@@ -0,0 +1,89 @@
1
+ // 專案手冊(程序層沉澱)— kernel 內建。agent 執行中把「這個專案怎麼做事」沉澱下來,跨 session 自動載入。
2
+ // 與 memory 的分工:memory 存「事實/偏好/決策」(扁平一行一條);playbook 存「可重複的程序知識」
3
+ // (建置/測試/部署指令、慣例、必經步驟、踩過的坑與修法),按 topic 組織、同 topic 覆蓋(天然去重)。
4
+ // 落地到 <cwd>/.xitto-kernel/<pack>/playbook.md → 因綁 cwd,天然只對「這個專案」生效(自帶相關性範圍)。
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
6
+ import { dirname } from 'node:path';
7
+
8
+ const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
9
+ const HEADER = '# 專案手冊';
10
+
11
+ // 解析:以 `## <topic>` 分節,標題下方至下一個 `##` 為 note。
12
+ function parse(md) {
13
+ if (!md) return [];
14
+ const entries = [];
15
+ for (const block of md.split(/\n(?=##\s)/)) {
16
+ const m = block.match(/^##\s+(.+?)(?:\n([\s\S]*))?$/);
17
+ if (!m) continue;
18
+ const topic = m[1].trim();
19
+ const note = (m[2] || '').trim();
20
+ if (topic) entries.push({ topic, note });
21
+ }
22
+ return entries;
23
+ }
24
+
25
+ function serialize(entries) {
26
+ return HEADER + '\n\n' + entries.map((e) => `## ${e.topic}\n${e.note}`).join('\n\n') + '\n';
27
+ }
28
+
29
+ /**
30
+ * @param {string} file 手冊檔路徑(如 <cwd>/.xitto-kernel/<pack>/playbook.md)
31
+ */
32
+ export function createPlaybook(file) {
33
+ const read = () => (existsSync(file) ? readFileSync(file, 'utf8') : '');
34
+ const list = () => parse(read());
35
+ const load = () => read().trim();
36
+
37
+ const writeEntries = (entries) => {
38
+ if (!entries.length) { try { if (existsSync(file)) unlinkSync(file); } catch { /* 略 */ } return; }
39
+ mkdirSync(dirname(file), { recursive: true });
40
+ writeFileSync(file, serialize(entries));
41
+ };
42
+
43
+ const update = (topic, note) => {
44
+ const t = String(topic || '').trim();
45
+ const n = String(note || '').trim();
46
+ if (!t) return { error: 'topic 不可為空' };
47
+ if (!n) return { error: 'note 不可為空' };
48
+ const entries = list();
49
+ const i = entries.findIndex((e) => e.topic.toLowerCase() === t.toLowerCase());
50
+ if (i >= 0) {
51
+ if (entries[i].note === n) return { skipped: true, topic: entries[i].topic };
52
+ entries[i] = { topic: entries[i].topic, note: n }; // 保留原始大小寫,只換內容
53
+ writeEntries(entries);
54
+ return { updated: entries[i].topic };
55
+ }
56
+ entries.push({ topic: t, note: n });
57
+ writeEntries(entries);
58
+ return { added: t };
59
+ };
60
+
61
+ const remove = (topic) => {
62
+ const t = String(topic || '').trim().toLowerCase();
63
+ const entries = list();
64
+ const kept = entries.filter((e) => e.topic.toLowerCase() !== t);
65
+ if (kept.length === entries.length) return { error: '找不到主題', topic };
66
+ writeEntries(kept);
67
+ return { removed: topic };
68
+ };
69
+
70
+ const clear = () => { const n = list().length; writeEntries([]); return { cleared: n }; };
71
+
72
+ // playbook_* 只動 kernel 自己的手冊檔(agent 簿記),標 readOnly → 守衛鏈自動放行
73
+ const tools = [
74
+ {
75
+ name: 'playbook_update', label: '記專案手冊', readOnly: true,
76
+ description: '把這個專案的「做事方法」(程序知識)記下來或更新:建置/測試/執行/部署指令、專案慣例、必經步驟、踩過的坑與修法。按 topic 組織,同 topic 會覆蓋(避免重複)。下次 session 自動載入,省得重新摸索。與 memory 的差別:memory 存事實/偏好/決策,playbook 存可重複的程序步驟。',
77
+ parameters: { type: 'object', properties: { topic: { type: 'string', description: '主題,如「測試」「建置」「部署地雷」' }, note: { type: 'string', description: '具體做法/步驟/注意事項' } }, required: ['topic', 'note'] },
78
+ execute: async (_id, { topic, note }) => txt(update(topic, note)),
79
+ },
80
+ {
81
+ name: 'playbook_remove', label: '清專案手冊', readOnly: true,
82
+ description: '移除一條過時的專案手冊條目(按 topic)。手冊內容過時或錯誤時清掉,避免誤導未來的 session。',
83
+ parameters: { type: 'object', properties: { topic: { type: 'string' } }, required: ['topic'] },
84
+ execute: async (_id, { topic }) => txt(remove(topic)),
85
+ },
86
+ ];
87
+
88
+ return { load, list, update, remove, clear, tools };
89
+ }
@@ -0,0 +1,40 @@
1
+ // 漸進式放權的「已信任」儲存:記住使用者批准過的工具/命令簽章,跨 session 累積。
2
+ // 兩種實作:memory(headless/測試,不落地)與 file(落地到 .xitto-kernel/<pack>/allow.json)。
3
+ // 格式與細粒度簽章見 allow.js(parseAllowFile / serializeAllow / commandSignature)。
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
5
+ import { dirname } from 'node:path';
6
+ import { parseAllowFile, serializeAllow } from './allow.js';
7
+
8
+ function makeStore(seed, flush, path) {
9
+ const tools = new Set(seed.tools || []);
10
+ const bash = new Set(seed.bash || []);
11
+ const save = () => flush?.(tools, bash);
12
+ return {
13
+ path: path || null,
14
+ hasTool: (n) => tools.has(n),
15
+ hasSig: (s) => bash.has(s),
16
+ addTool: (n) => { if (!tools.has(n)) { tools.add(n); save(); } },
17
+ addSig: (s) => { if (s && !bash.has(s)) { bash.add(s); save(); } },
18
+ // forget:可傳工具名或 bash 簽章;回傳是否真的移除了
19
+ remove: (entry) => { const a = tools.delete(entry); const b = bash.delete(entry); if (a || b) save(); return a || b; },
20
+ clear: () => { const had = tools.size + bash.size > 0; tools.clear(); bash.clear(); if (had) save(); return had; },
21
+ list: () => ({ tools: [...tools], bash: [...bash] }),
22
+ size: () => tools.size + bash.size,
23
+ };
24
+ }
25
+
26
+ // 記憶體版:session 內有效,重啟即忘(headless / 測試 / 明確關閉持久化時)。
27
+ export function memoryAllowStore(seed = {}) {
28
+ return makeStore(seed, null, null);
29
+ }
30
+
31
+ // 檔案版:啟動時載入既有信任,每次變更立即落地。重啟後信任仍在 → 漸進累積。
32
+ export function fileAllowStore(path) {
33
+ let parsed = { tools: [], bash: [] };
34
+ try { if (existsSync(path)) parsed = parseAllowFile(JSON.parse(readFileSync(path, 'utf8'))); } catch { /* 壞檔忽略,當空 */ }
35
+ const flush = (tools, bash) => {
36
+ try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, serializeAllow(tools, bash) + '\n'); }
37
+ catch { /* 落地失敗不影響本回合放行 */ }
38
+ };
39
+ return makeStore(parsed, flush, path);
40
+ }
@@ -5,6 +5,7 @@
5
5
  import { sandboxViolation } from './sandbox.js';
6
6
  import { dangerousReason } from './danger.js';
7
7
  import { commandSignature } from './allow.js';
8
+ import { memoryAllowStore } from './allow-store.js';
8
9
 
9
10
  /**
10
11
  * @param {Object} o
@@ -12,14 +13,15 @@ import { commandSignature } from './allow.js';
12
13
  * @param {() => boolean} [o.getSandbox] 沙箱是否開啟
13
14
  * @param {() => object} [o.getSandboxConfig] 沙箱策略(blockNetwork/allowWritePrefixes)
14
15
  * @param {string[]} [o.deny] 禁止的工具名 / "bash:<簽章>"
15
- * @param {(name: string, args: object, danger: string|null) => Promise<'yes'|'no'|'always'|'command'>} [o.confirm]
16
+ * @param {object} [o.store] 已信任儲存(漸進放權,跨 session);預設記憶體版
17
+ * @param {(name: string, args: object, danger: string|null, meta: {signature?: string}) => Promise<'yes'|'no'|'always'|'command'>} [o.confirm]
16
18
  * 互動確認;不提供(headless)時:危險命令一律擋、其餘放行(沙箱靜態違規仍先擋)。
19
+ * @param {(info: {name: string, signature: string|null, scope: 'tool'|'command'}) => void} [o.onTrusted]
20
+ * 從已信任儲存自動放行時通知(讓 app 顯示「已信任」,維持可理解性)。
17
21
  * @returns {(ctx: { name: string, args: object }) => Promise<import('../../types.js').PolicyDecision>}
18
22
  */
19
- export function createPermissionStep({ registry, getSandbox, getSandboxConfig, deny = [], confirm }) {
23
+ export function createPermissionStep({ registry, getSandbox, getSandboxConfig, deny = [], confirm, store = memoryAllowStore(), onTrusted }) {
20
24
  const denySet = new Set(deny);
21
- const allowedSignatures = new Set(); // session 內「允許此命令簽章全部」
22
- const alwaysTools = new Set(); // session 內「允許此工具全部」(使用者選 always)
23
25
 
24
26
  return async function permission(ctx) {
25
27
  const name = ctx.name;
@@ -45,18 +47,19 @@ export function createPermissionStep({ registry, getSandbox, getSandboxConfig, d
45
47
  // 3) 危險命令:即使 always-allow / 無 confirm 也強制把關(headless 直接擋)
46
48
  const danger = isShell ? dangerousReason(cmd) : null;
47
49
 
48
- // 4) 非危險:本工具/命令簽章已 always-allow 直接過;headless(無 confirm)→ 放行
50
+ // 4) 非危險:已信任(本工具 or 命令簽章)→ 直接過;headless(無 confirm)→ 放行
49
51
  if (!danger) {
50
- if (alwaysTools.has(name) || (sig && allowedSignatures.has(sig))) return undefined;
52
+ if (store.hasTool(name)) { onTrusted?.({ name, signature: null, scope: 'tool' }); return undefined; }
53
+ if (sig && store.hasSig(sig)) { onTrusted?.({ name, signature: sig, scope: 'command' }); return undefined; }
51
54
  if (!confirm) return undefined;
52
55
  } else if (!confirm) {
53
56
  return { block: true, reason: `偵測到危險命令(${danger}),headless 模式下拒絕執行。` };
54
57
  }
55
58
 
56
- // 5) 互動確認(危險命令即使選 always 也只放行這次,不永久放行)
57
- const decision = await confirm(name, ctx.args, danger);
58
- if (decision === 'always' && !danger) { alwaysTools.add(name); return undefined; }
59
- if (decision === 'command' && sig && !danger) { allowedSignatures.add(sig); return undefined; }
59
+ // 5) 互動確認(危險命令即使選 always 也只放行這次,不寫入信任)
60
+ const decision = await confirm(name, ctx.args, danger, { signature: sig || null });
61
+ if (decision === 'always' && !danger) { store.addTool(name); return undefined; }
62
+ if (decision === 'command' && sig && !danger) { store.addSig(sig); return undefined; }
60
63
  if (decision === 'yes') return undefined;
61
64
  return { block: true, reason: `使用者拒絕執行 ${name}。` };
62
65
  };