xitto-kernel 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - **持久工作空間(許願台成品間的關係)**:每個成品仍是獨立對話,但共用一個持久工作空間。
6
+ - server workdir 改綁 `workspace`(`.xitto-server/ws/<workspace>`,預設 `default`)而非每 job 丟棄式 sessionId
7
+ - 效果:① 檔案留存,後續任務能接續前面成果;② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——越用越懂你
8
+ - history 仍每 job 獨立(不續接,避免 context 暴脹);`workspace` 可在 POST 指定(多使用者隔離)
9
+ - 交付檔案端點與 webhook 改用 workspace 解析;result 帶 `workspace`
10
+ - **待辦打勾**:`todo_write` 的清單進 `progress.todos`,UI 顯示 ☐/◐/☑(把「未知時長」變「可見剩餘步數」,對標 Claude Code)
11
+ - **可中斷(取消鈕)**:`POST /v1/tasks/:id/cancel` → abort 進行中 agent / 移除排隊 / 解除待答;UI「停止」鈕;狀態 `cancelled`
12
+ - 5 個新測試(取消 running/queued/已結束/待答 + todo 進度)+ 真實 server 端到端
13
+ (Job2 接續 Job1 的檔案與記憶、todo 打勾、長任務中途取消)
14
+ - 緣由:對標 Claude Code 處理「等待焦慮」——liveness(心跳)+ transparency(進度/待辦)+ control(可中斷)
15
+
16
+ ## 0.4.6
17
+
18
+ - **許願台「活著的證明」**:解決「只顯示進行中、不知道是否真的在跑」。
19
+ - **每秒心跳時鐘**「已進行 Ns」:UI 端 1 秒 ticker(不靠 poll),即使沒有新事件也持續跳動 → 看得到它活著
20
+ - **思考文字可見**:progress 新增 `thinking`——累積 agent 當下串流的文字,在「思考中」階段顯示 💭 它在想什麼
21
+ (tool/round 後清空;不存進 view 的 buffer 用 `t._textbuf`)
22
+ - phase 新增 `thinking`;poll 由 1500ms 縮到 1200ms
23
+ - 真實 live 驗證:時鐘 0→16s 連續跳動,階段 starting→thinking(💭)→acting(建檔→讀檔)→done
24
+ - 1 個新測試(text 事件累積 thinking、tool/round 清空)。測試 166/166。
25
+
26
+ ## 0.4.5
27
+
28
+ - **修:CLI 澄清提問被 spinner 蓋住,導致回答疑似沒被採用**。
29
+ - `askUserQuestion` / `askConfirm` 提問前先 `stopSpin()`——否則「思考中…」spinner 每 100ms 覆蓋掉
30
+ `❓ 問題` 與你的輸入列,使用者根本看不到 agent 在問,打的字也對不上 → 看起來像回答被忽略
31
+ - ask_user 提問加上「agent 想問你:」更醒目
32
+ - `ask_user` 工具結果改為自帶 `{ question, answer, note }`:把回答標為權威依據,長對話也不脫鉤
33
+ - 驗證:kernel/server 的回答鏈本來就正確(單輪 + 多輪 live 測試:round-1 回答在 round-2 仍被採用);
34
+ 本修針對互動 CLI 的顯示遮蔽問題
35
+
36
+ ## 0.4.4
37
+
38
+ - **即時進度(許願台不再只顯示「進行中」)**:讓非技術使用者看得到 agent 在做什麼。
39
+ - 任務 view 新增 `progress`:`{ phase, round, steps, recent[] }`——從事件流累積(排除 text 雜訊)
40
+ - `mapEvent` 補 `round` / `verify`→`phase` 事件;goal 模式 wire `onRound` → 進度有輪數
41
+ - 網頁把工具動作翻成人話(讀取檔案/執行指令/搜尋網路…)+ 第幾輪 + 動作數 + 動畫指示,取代靜態轉圈
42
+ - `recent` 只留最近 6 個動作避免膨脹
43
+ - 3 個測試(mapEvent 新事件 + progress 累積/上限)+ 真實端到端(進度快照逐步演進)
44
+
45
+ ## 0.4.3
46
+
47
+ - **許願台網頁(結果導向第三刀)**:給非技術使用者的瀏覽器介面,以結果為中心、不是聊天。
48
+ - server 服務 `GET /` → 單一 HTML(`src/app/web/index.html`,零依賴 vanilla,polling 不靠 SSE)
49
+ - 介面:許願(送目標)→ 進行中狀態 → needs-input 時跳問題+回答框 → 收成品(摘要+產出檔案)→ 歷史清單
50
+ - 新增 `GET /v1/tasks/:id/file?path=`:取交付檔案內容(點檔名看成品);`resolveArtifact` 防路徑穿越
51
+ - 任務 view 補 `goal`(顯示願望);token 注入頁面供同源呼叫(本地自用零設定,正式部署需前置認證)
52
+ - 2 個測試(resolveArtifact 穿越防護 + GET / 服務頁面/token 注入/API 仍需 auth)+ 真實端到端
53
+ (許願→交付 hello.txt→點開看內容→穿越攻擊擋下)
54
+ - 「許願→交付」三刀完成:交付抽象 + 澄清通道 + Job 介面
55
+
56
+ ## 0.4.2
57
+
58
+ - **澄清通道(結果導向第二刀)**:agent 只在非問不可時暫停提問,而非盲猜或頻繁打擾。
59
+ - 新增 `ask_user` 工具(app 提供 `config.askUser` 才注入;readOnly);prompt 引導節制使用(能合理推斷就別問)
60
+ - **CLI**:`askUser` 內嵌提問,使用者打字回答,agent 續跑
61
+ - **背景任務 pause/resume**:`createTaskStore` 的 `runJob` 多收 `ask`;呼叫即轉 `needs-input` 並掛起問題;
62
+ 新增 `POST /v1/tasks/:id/answer` 回答後解除暫停、續跑(完全非同步,可隔很久才答)
63
+ - 任務 view 帶 `pending`(待答問題);事件流發 `needs_input` / `answered`
64
+ - 4 個測試(工具有無/回空提示 + 佇列 pause/answer/resume + 無待答回 false)+ 真實 server 端到端
65
+ (under-spec 目標 → agent 暫停問檔名/內容 → 答完交付正確檔案)
66
+
67
+ ## 0.4.1
68
+
69
+ - **結果導向:交付物為一等公民(「對話只是過程」)**:第一刀朝非技術使用者的「許願→交付」模型。
70
+ - 新增 `api.runOutcome(goal, opts)`:跑 goal loop,回傳**交付物** `{ done, summary, artifacts:{created,modified}, rounds, history }`
71
+ - 交付物偵測:掃工作目錄前後 diff(pack 無關,連 bash 寫的檔也抓;排除 .xitto-kernel/node_modules/.git)
72
+ - `--goal` 改印交付物(📦 產出/改動檔案 + 📝 摘要),不再只報達成輪數
73
+ - server `POST /v1/tasks`(mode=goal)回 `artifacts`;背景任務 webhook payload 也帶 `artifacts`
74
+ - 3 個測試(created/modified/無變動 + 內部沉澱檔不算交付物)+ 真實 model 端到端(產出 greet.js/example.js)
75
+ - 後續規劃:澄清通道(ask_user 暫停/續跑)、Job 介面(成品歷史)
76
+
3
77
  ## 0.4.0
4
78
 
5
79
  **「執行中沉澱經驗」五層完整**(反射 / 事實 / 程序 / 情節 / 結晶)——里程碑。
package/README.md CHANGED
@@ -78,6 +78,38 @@ xitto-kernel --pack general --yes --goal "抓取 example.com 摘要成繁中寫
78
78
  ```
79
79
  `general` pack(檔案/shell/web_fetch)+ kernel 的 **goal loop**(反覆 runTurn + LLM 自我驗收,直到達成/無進展/上限)。互動模式用 `/goal <目標>`。
80
80
 
81
+ **結果導向:對話只是過程,交付物才是產品**
82
+
83
+ 對非技術使用者,真正要的不是「跟 AI 聊天」,是「把事做完、給我結果」。`api.runOutcome(goal)` 跑 goal loop,回傳的不是對話而是**交付物**:
84
+ ```js
85
+ const o = await kernel.runOutcome('建立 greet.js 並寫個範例驗證');
86
+ // → { done, summary(做了什麼), artifacts: { created:[...], modified:[...] }, rounds }
87
+ ```
88
+ `--goal` 與 server 的 `POST /v1/tasks`(mode=goal)都會回交付物——**產出/改動的檔案**(掃工作目錄前後 diff,連 bash 寫的也抓)+ 摘要 + 是否達成。對話被降格成過程,結果(檔案/達成)被擺到最前面。背景任務的 webhook 也帶 `artifacts`。
89
+
90
+ **澄清通道(只在非問不可時才打斷你)**:自主交付的風險是「自主走錯」。`ask_user` 工具讓 agent 在**缺少關鍵資訊、無法合理推斷**時暫停提問——而非盲猜或頻繁打擾(prompt 明確引導:能用合理預設就別問)。由 app 注入 `config.askUser` 決定「問」的形態:
91
+ - **CLI**:內嵌提問,你打字回答,agent 續跑
92
+ - **背景任務**:任務轉 `needs-input` 狀態並掛起問題 → 你 `POST /v1/tasks/:id/answer` 回答 → 解除暫停、續跑(可隔數小時才答,完全非同步)
93
+
94
+ 實測:給「建個設定檔但檔名/內容我還沒決定」→ agent 不亂猜,暫停問你檔名與內容 → 答完才交付正確的 `app.config.json`。這讓「許願→交付」既自主又不失控。
95
+
96
+ **🪄 許願台網頁(給非技術使用者:瀏覽器打開就用)**
97
+ ```bash
98
+ XITTO_SERVER_TOKEN=secret npm run serve # 然後瀏覽器開 http://localhost:8787/
99
+ ```
100
+ 不用終端機、不用碰金鑰(伺服器端管)。介面以**結果**為中心,不是聊天:
101
+ - **許願**:打一句「你想完成什麼」→ 交辦(背景跑 goal loop)
102
+ - **進行中**:**即時進度 + 活著的證明**——每秒跳動的「已進行 Ns」心跳時鐘、目前階段(思考中/執行中/驗收中)、agent 當下的**思考文字**(💭)、工具動作翻成人話、第幾輪 + 動作數。看得到它在想什麼、做什麼
103
+ - **待辦打勾**:agent 用 `todo_write` 規劃多步任務時,顯示 ☐/◐/☑ 清單,把「未知時長」變成「看得到的剩餘步數」(對標 Claude Code)
104
+ - **隨時可停**:每個進行中任務有「停止」鈕 → `POST /v1/tasks/:id/cancel`(abort 正在跑的 agent)。控制權在使用者手上,降低「啟動了控制不了的東西」的焦慮
105
+ - **需要你回答**:agent 暫停提問時,跳出問題 + 回答框(澄清通道)
106
+ - **收成品**:完成後顯示摘要 + **產出的檔案**,點檔名可直接看內容(`GET /v1/tasks/:id/file`,防路徑穿越)
107
+ - **歷史成品**:過往交辦的清單(願望 + 狀態),不是聊天串
108
+
109
+ **持久工作空間(成品間的關係)**:每個成品是**獨立的對話**(不續接前一個,避免 context 暴脹),但**共用一個持久工作空間**(`.xitto-server/ws/<workspace>`,預設 `default`)——所以 ① **檔案留存**,後面的任務能接續前面的成果(「把我上次做的 plan.md 翻成英文」);② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——它**越用越懂你**,不再是每次都從零開始的陌生人。`workspace` 可在 POST 時指定(多使用者各自一個)。
110
+
111
+ 零依賴單一 HTML(`src/app/web/index.html`),polling 不靠 SSE。token 注入頁面供同源呼叫——本地自用零設定;**正式部署請前置真實認證**。
112
+
81
113
  ## 當成服務跑(不只 CLI)
82
114
 
83
115
  kernel 是 UI 無關的,CLI 只是其中一個 app。`src/app/server.js` 是把它包成 **HTTP 服務**的 PoC
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
package/src/app/cli.js CHANGED
@@ -37,6 +37,8 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
37
37
  getPlanMode: () => planMode, // 計劃模式:守衛擋 mutating 工具
38
38
  autoExtractMemory: true, // 事實層:每輪後自動萃取持久事實進記憶(非阻塞)
39
39
  confirm: askConfirm, // 互動權限確認(mutating/危險工具執行前)
40
+ askUser: askUserQuestion, // 澄清通道:agent 非問不可時向使用者提問並等待
41
+
40
42
  onTrusted: ({ name, signature, scope }) => { // 漸進放權:自動放行時標示「已信任」(維持可理解)
41
43
  endStream();
42
44
  out(c.gray(` ✓ 已信任 ${scope === 'command' ? `「${signature}」類` : name},自動放行\n`));
@@ -71,6 +73,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
71
73
  // 回 'yes'(允許一次)/ 'command'(信任此命令簽章類,跨 session)/ 'always'(信任此工具全部)/ 'no'(拒絕)。
72
74
  async function askConfirm(name, args, danger, meta = {}) {
73
75
  if (autoApprove && !danger) return 'yes'; // 自動模式仍對危險命令把關
76
+ stopSpin(); // 停 spinner,否則它每 100ms 蓋掉提問/輸入列
74
77
  endStream();
75
78
  const sig = meta.signature; // 有簽章(bash 類)才提供細粒度「信任這類命令」
76
79
  return new Promise((res) => {
@@ -92,6 +95,17 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
92
95
  });
93
96
  }
94
97
 
98
+ // 澄清通道:agent 呼叫 ask_user 時,內嵌問使用者並等待回答(自由文字;options 只當提示)
99
+ async function askUserQuestion({ question, options }) {
100
+ stopSpin(); // 關鍵:停 spinner,否則它會蓋掉問題與你的輸入,讓你看不到 agent 在問
101
+ endStream();
102
+ out('\n' + c.cyan(' ❓ agent 想問你:') + c.bold(String(question || '')) + '\n');
103
+ if (Array.isArray(options) && options.length) out(c.gray(' 選項:' + options.map((o, i) => `${i + 1}) ${o}`).join(' ')) + '\n');
104
+ return new Promise((res) => {
105
+ try { rl.question(c.cyan(' 你的回答 › '), (ans) => res((ans || '').trim())); } catch { res(''); }
106
+ });
107
+ }
108
+
95
109
  // session:續接(--resume [id])或開新;每輪結束自動存檔
96
110
  let sessionId = kernel.session.newId();
97
111
  let resumedNote = '';
package/src/app/main.js CHANGED
@@ -87,7 +87,7 @@ export async function main(argv = process.argv.slice(2)) {
87
87
  confirm: opts.yes ? (async () => 'yes') : undefined, // headless:--yes 才自動核准 mutating
88
88
  });
89
89
  console.log(cyan('🎯 目標:') + opts.goal + gray(` · ${opts.pack} pack · ${model.id}`));
90
- const res = await kernel.runGoal(opts.goal, {
90
+ const res = await kernel.runOutcome(opts.goal, {
91
91
  onRound: ({ round, maxRounds }) => console.log(yellow(`\n🔁 第 ${round}/${maxRounds} 輪`)),
92
92
  onCheck: ({ done, remaining }) => console.log(done ? green(' ✓ 驗收:已達成') : gray(` ↻ 驗收:${remaining}`)),
93
93
  onEvent: (ev) => {
@@ -95,8 +95,16 @@ export async function main(argv = process.argv.slice(2)) {
95
95
  if (ev.type === 'tool_execution_end') console.log(ev.isError ? red(' ⎿ ✗') : gray(' ⎿ ✓'));
96
96
  },
97
97
  });
98
- const why = res.stalled ? '無進展停止' : res.aborted ? '中斷' : res.verifyBroken ? '驗收持續失敗' : '到上限';
99
- console.log('\n' + (res.done ? green(`✅ 目標達成(${res.rounds} 輪)`) : yellow(`⚠ 未達成(${why},${res.rounds} 輪)`)));
98
+ // 交付物(對話只是過程,這才是產品)
99
+ const why = res.stalled ? '無進展停止' : res.aborted ? '中斷' : '到上限';
100
+ console.log('\n' + (res.done ? green(`✅ 已交付(${res.rounds} 輪)`) : yellow(`⚠ 未完成(${why},${res.rounds} 輪)`)));
101
+ const { created, modified } = res.artifacts;
102
+ if (created.length || modified.length) {
103
+ console.log(cyan('📦 產出檔案:'));
104
+ created.forEach((f) => console.log(green(` + ${f}`)));
105
+ modified.forEach((f) => console.log(yellow(` ~ ${f}`)));
106
+ } else console.log(gray(' (沒有檔案變動)'));
107
+ if (res.summary) console.log(cyan('📝 摘要:') + gray(res.summary.slice(0, 400)));
100
108
  try { await mcp.close(); } catch { /* 略 */ }
101
109
  process.exit(res.done ? 0 : 1);
102
110
  }
package/src/app/server.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // JSON 或 SSE 串流,以及「背景任務 + 完成通知(webhook)」—— 派任務出去、做完回呼,不用一直盯著。
4
4
  // 這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
5
5
  import { createServer } from 'node:http';
6
- import { mkdirSync } from 'node:fs';
7
- import { join } from 'node:path';
6
+ import { mkdirSync, readFileSync, existsSync } from 'node:fs';
7
+ import { join, dirname, isAbsolute, relative } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { createKernel } from '../kernel/index.js';
10
10
  import { loadModel } from './providers.js';
@@ -23,11 +23,25 @@ const PACKS = {
23
23
  const lastText = (history) => ([...(history || [])].reverse().find((m) => m.role === 'assistant')?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
24
24
  const newId = (p = 's') => p + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
25
25
 
26
+ // 交付檔案路徑解析(防穿越):rel 必須是 workdir 內的相對路徑,否則回 null。
27
+ export function resolveArtifact(workdir, rel) {
28
+ if (typeof rel !== 'string' || !rel || isAbsolute(rel)) return null;
29
+ const full = join(workdir, rel);
30
+ const r = relative(workdir, full);
31
+ return (r.startsWith('..') || isAbsolute(r)) ? null : full;
32
+ }
33
+
34
+ let _webHtml;
35
+ const webHtml = () => (_webHtml ??= readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'web', 'index.html'), 'utf8'));
36
+
26
37
  // 把原始 kernel 事件壓成精簡的對外事件(串流端與背景任務共用,避免重複映射)
27
38
  export const mapEvent = (ev) => {
28
39
  if (ev.type === 'tool_execution_start') return { type: 'tool', name: ev.toolName, args: ev.args };
29
40
  if (ev.type === 'tool_execution_end') return { type: 'tool_end', name: ev.toolName, isError: !!ev.isError };
30
41
  if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') return { type: 'text', delta: ev.assistantMessageEvent.delta };
42
+ if (ev.type === 'round') return { type: 'round', round: ev.round, maxRounds: ev.maxRounds };
43
+ if (ev.type === 'verify_start') return { type: 'phase', phase: 'verifying' };
44
+ if (ev.type === 'verify_end') return { type: 'phase', phase: ev.ok ? 'verified' : 'fixing' };
31
45
  return null;
32
46
  };
33
47
 
@@ -46,14 +60,31 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
46
60
  const subs = new Map(); // id -> Set<(ev)=>void>
47
61
  let active = 0;
48
62
 
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 });
63
+ const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', goal: t.spec.goal || t.spec.input || '', sessionId: t.result?.sessionId || t.spec.sessionId || null, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error, pending: t.pending || null, progress: t.progress || null });
50
64
 
51
65
  const emit = (t, ev) => {
52
66
  t.events.push(ev);
53
67
  if (t.events.length > maxEvents) t.events.shift();
68
+ // 進度追蹤(給 UI 顯示「正在做什麼」,不要只顯示進行中;排除 text 雜訊)
69
+ const p = (t.progress ||= { steps: 0, round: 0, maxRounds: 0, recent: [], phase: 'starting', thinking: '', todos: [] });
70
+ if (ev.type === 'tool' && ev.name === 'todo_write') { if (Array.isArray(ev.args?.todos)) p.todos = ev.args.todos; } // 待辦清單(給 UI 打勾)
71
+ else if (ev.type === 'tool') { p.steps++; p.phase = 'acting'; p.thinking = ''; t._textbuf = ''; p.recent.push({ name: ev.name, args: ev.args }); if (p.recent.length > 6) p.recent.shift(); }
72
+ else if (ev.type === 'text') { p.phase = 'thinking'; t._textbuf = ((t._textbuf || '') + (ev.delta || '')).slice(-400); p.thinking = t._textbuf.replace(/\s+/g, ' ').trim().slice(-150); }
73
+ else if (ev.type === 'round') { p.round = ev.round; if (ev.maxRounds) p.maxRounds = ev.maxRounds; p.thinking = ''; t._textbuf = ''; }
74
+ else if (ev.type === 'phase') p.phase = ev.phase;
75
+ else if (ev.type === 'needs_input') p.phase = 'needs-input';
76
+ else if (ev.type === 'answered') p.phase = 'acting';
77
+ else if (ev.type === 'end') p.phase = ev.status;
54
78
  const s = subs.get(t.id); if (s) for (const fn of s) { try { fn(ev); } catch { /* 訂閱端錯不影響任務 */ } }
55
79
  };
56
80
 
81
+ // 澄清通道:job 呼叫 ask({question,options}) → 任務轉 needs-input、暫停,直到有人 answer()
82
+ const makeAsk = (t) => ({ question, options }) => {
83
+ t.status = 'needs-input'; t.pending = { question: String(question || ''), options: options || null };
84
+ emit(t, { type: 'needs_input', question: t.pending.question, options: t.pending.options });
85
+ return new Promise((resolve) => { t._answer = resolve; });
86
+ };
87
+
57
88
  function pump() {
58
89
  while (active < concurrency && queue.length) {
59
90
  const t = queue.shift();
@@ -61,10 +92,11 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
61
92
  t.status = 'running'; t.startedAt = new Date().toISOString();
62
93
  emit(t, { type: 'status', status: 'running' });
63
94
  Promise.resolve()
64
- .then(() => runJob(t.spec, (ev) => emit(t, ev)))
95
+ .then(() => runJob(t.spec, (ev) => emit(t, ev), makeAsk(t), (agent) => { t._agent = agent; }))
65
96
  .then((result) => { t.status = 'done'; t.result = result; })
66
97
  .catch((e) => { t.status = 'error'; t.error = e.message || String(e); })
67
98
  .finally(() => {
99
+ if (t._cancelling && t.status !== 'error') t.status = 'cancelled'; // 使用者中斷
68
100
  t.finishedAt = new Date().toISOString();
69
101
  emit(t, { type: 'end', status: t.status, result: t.result, error: t.error });
70
102
  active--;
@@ -87,6 +119,31 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
87
119
  result: (id) => { const t = tasks.get(id); return t ? { ...view(t), result: t.result } : null; },
88
120
  list: () => [...tasks.values()].map(view),
89
121
  subscribe(id, fn) { let s = subs.get(id); if (!s) { s = new Set(); subs.set(id, s); } s.add(fn); return () => s.delete(fn); },
122
+ // 中斷任務(取消鈕):排隊中 → 直接移除;進行中 → abort agent;待答中 → 解除阻塞後 abort。
123
+ cancel(id) {
124
+ const t = tasks.get(id);
125
+ if (!t || ['done', 'error', 'cancelled'].includes(t.status)) return false;
126
+ t._cancelling = true;
127
+ if (t.status === 'queued') {
128
+ const i = queue.indexOf(t); if (i >= 0) queue.splice(i, 1);
129
+ t.status = 'cancelled'; t.finishedAt = new Date().toISOString();
130
+ emit(t, { type: 'end', status: 'cancelled' });
131
+ return true;
132
+ }
133
+ if (typeof t._answer === 'function') { const r = t._answer; t._answer = null; t.pending = null; r(''); } // 解除待答阻塞
134
+ if (t._agent && typeof t._agent.abort === 'function') { try { t._agent.abort(); } catch { /* 略 */ } }
135
+ emit(t, { type: 'cancelling' });
136
+ return true;
137
+ },
138
+ // 回答一個待答任務 → 解除暫停、續跑。回 true 表示有對應的待答問題。
139
+ answer(id, text) {
140
+ const t = tasks.get(id);
141
+ if (!t || typeof t._answer !== 'function') return false;
142
+ const resolve = t._answer; t._answer = null; t.pending = null; t.status = 'running';
143
+ emit(t, { type: 'answered', answer: String(text ?? '') });
144
+ resolve(String(text ?? ''));
145
+ return true;
146
+ },
90
147
  stats: () => ({ active, queued: queue.length, total: tasks.size }),
91
148
  };
92
149
  }
@@ -111,35 +168,43 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
111
168
  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
169
  const sseHead = (res) => res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
113
170
 
114
- // 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件
115
- async function runKernel(spec, onEvent) {
171
+ // 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件;
172
+ // ask(可選)= 澄清通道,讓 agent 在背景任務中暫停問使用者。
173
+ async function runKernel(spec, onEvent, ask, onAgent) {
116
174
  const make = PACKS[spec.pack || 'general'];
117
175
  if (!make) throw new Error(`未知 pack「${spec.pack}」,可用:${Object.keys(PACKS).join(', ')}`);
176
+ // 持久工作空間(B 模型):workdir 綁 workspace(非 sessionId)→ 檔案留存 + 五層沉澱跨成品累積。
177
+ const workspace = (spec.workspace || 'default').replace(/[^a-zA-Z0-9_-]/g, '') || 'default';
178
+ const workdir = join(baseDir, 'ws', workspace); mkdirSync(workdir, { recursive: true });
179
+ // history 仍綁 sessionId(每個成品獨立對話:無 sessionId → 全新,不續接,避免 context 暴脹/混淆)
118
180
  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' });
181
+ const sess = sessions.get(sessionId) || { history: [] };
182
+ const kernel = createKernel(make({ cwd: workdir }), { cwd: workdir, model, getApiKey, sandbox: { enabled: sandbox }, getSandbox: () => sandbox, confirm: async () => 'yes', autoExtractMemory: true, ...(ask ? { askUser: ask } : {}) });
122
183
  const usage = { input: 0, output: 0 };
123
184
  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 });
185
+ if (spec.mode === 'goal') {
186
+ // 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
187
+ const o = await kernel.runOutcome(spec.goal || spec.input || '', { history: sess.history, onEvent: wrapped, onAgent, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
188
+ sess.history = o.history || []; sessions.set(sessionId, sess);
189
+ return { sessionId, workspace, text: o.summary || lastText(sess.history), usage, rounds: o.rounds, done: o.done, aborted: o.aborted, artifacts: o.artifacts };
190
+ }
191
+ const r = await kernel.runTurn(spec.input || '', { history: sess.history, onEvent: wrapped, onAgent });
127
192
  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 };
193
+ return { sessionId, workspace, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
129
194
  }
130
195
 
131
196
  // 完成通知:POST 結果到 spec.webhook(http/https),單次嘗試、失敗記日誌不重試(PoC)
132
197
  async function fireWebhook(task) {
133
198
  const url = task.spec.webhook; if (!url || !/^https?:\/\//.test(url)) return;
134
199
  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 });
200
+ 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, artifacts: r.artifacts, finishedAt: task.finishedAt });
136
201
  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
202
  catch (e) { log({ webhook: url, task: task.id, error: e.message }); }
138
203
  }
139
204
 
140
205
  const tasks = createTaskStore({
141
206
  concurrency,
142
- runJob: (spec, emit) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }),
207
+ runJob: (spec, emit, ask, onAgent) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }, ask, onAgent),
143
208
  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
209
  });
145
210
 
@@ -147,6 +212,14 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
147
212
  const url = new URL(req.url, 'http://localhost');
148
213
  const path = url.pathname;
149
214
  if (req.method === 'GET' && path === '/health') return json(res, 200, { ok: true, packs: Object.keys(PACKS), model: model.id, tasks: tasks.stats() });
215
+
216
+ // 「許願台」網頁(公開可載入;token 注入頁面供同源 API 呼叫——PoC/本地自用,正式部署請前置真實認證)
217
+ if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
218
+ let html; try { html = webHtml(); } catch { return json(res, 500, { error: 'web UI 未找到' }); }
219
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
220
+ return res.end(html.replace(/__SERVER_TOKEN__/g, token || '').replace(/__PACKS__/g, JSON.stringify(Object.keys(PACKS))));
221
+ }
222
+
150
223
  if (!authed(req)) return json(res, 401, { error: 'unauthorized(帶 Authorization: Bearer <token>)' });
151
224
 
152
225
  // 同步:跑完才回(JSON 或 SSE 串流)
@@ -182,6 +255,38 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
182
255
  const mTask = path.match(/^\/v1\/tasks\/([^/]+)$/);
183
256
  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
257
 
258
+ // 回答待答任務(澄清通道):背景任務問了問題,使用者把答案送回 → 解除暫停、續跑
259
+ const mAns = path.match(/^\/v1\/tasks\/([^/]+)\/answer$/);
260
+ if (req.method === 'POST' && mAns) {
261
+ const body = await readBody(req);
262
+ const t = tasks.get(mAns[1]);
263
+ if (!t) return json(res, 404, { error: 'task not found' });
264
+ if (t.status !== 'needs-input') return json(res, 409, { error: '此任務目前沒有待答問題', status: t.status });
265
+ tasks.answer(mAns[1], body.answer);
266
+ log({ task: mAns[1], action: 'answer' });
267
+ return json(res, 200, { ok: true, taskId: mAns[1], status: 'running' });
268
+ }
269
+
270
+ // 中斷任務(取消鈕):控制權在使用者手上,降低「啟動了控制不了的東西」的焦慮
271
+ const mCancel = path.match(/^\/v1\/tasks\/([^/]+)\/cancel$/);
272
+ if (req.method === 'POST' && mCancel) {
273
+ const ok = tasks.cancel(mCancel[1]);
274
+ log({ task: mCancel[1], action: 'cancel', ok });
275
+ return ok ? json(res, 200, { ok: true, taskId: mCancel[1] }) : json(res, 409, { error: '無法中斷(任務不存在或已結束)' });
276
+ }
277
+
278
+ // 取交付物檔案內容(讓「成品」可被瀏覽/下載)
279
+ const mFile = path.match(/^\/v1\/tasks\/([^/]+)\/file$/);
280
+ if (req.method === 'GET' && mFile) {
281
+ const t = tasks.get(mFile[1]); const ws = t?.result?.workspace;
282
+ if (!ws) return json(res, 404, { error: '無交付物(任務尚未完成?)' });
283
+ const full = resolveArtifact(join(baseDir, 'ws', ws), url.searchParams.get('path'));
284
+ if (!full) return json(res, 400, { error: 'path 不合法' });
285
+ if (!existsSync(full)) return json(res, 404, { error: '檔案不存在' });
286
+ try { res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); return res.end(readFileSync(full)); }
287
+ catch (e) { return json(res, 500, { error: e.message }); }
288
+ }
289
+
185
290
  // 附掛背景任務的事件流(replay 緩衝 + 即時;已結束則回放後關閉)
186
291
  const mEv = path.match(/^\/v1\/tasks\/([^/]+)\/events$/);
187
292
  if (req.method === 'GET' && mEv) {
@@ -208,9 +313,10 @@ export function startServer() {
208
313
  const { model, getApiKey } = loadModel(process.env.XITTO_MODEL);
209
314
  const server = createServerApp({ model, getApiKey, token, sandbox, concurrency });
210
315
  server.listen(port, () => {
211
- console.log(`xitto-kernel server · http://localhost:${port} · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}`);
316
+ console.log(`🪄 許願台:http://localhost:${port}/ (瀏覽器打開即用——說出目標、交付成品)`);
317
+ console.log(`xitto-kernel server · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}`);
212
318
  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');
319
+ console.log('API:POST /v1/run · /v1/stream · /v1/tasks · /v1/tasks/:id/{answer,cancel}|GET /v1/tasks[/:id[/events|/file]] · /health');
214
320
  });
215
321
  return server;
216
322
  }
@@ -0,0 +1,180 @@
1
+ <!doctype html>
2
+ <html lang="zh-Hant">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>xitto · 許願台</title>
7
+ <style>
8
+ :root { --bg:#0f1115; --card:#181b22; --line:#272b34; --fg:#e7e9ee; --dim:#8b919e; --accent:#6ea8fe; --ok:#5fd38a; --warn:#f0c674; --me:#222630; }
9
+ * { box-sizing:border-box; }
10
+ body { margin:0; font:15px/1.6 -apple-system,"PingFang TC","Microsoft JhengHei",system-ui,sans-serif; background:var(--bg); color:var(--fg); }
11
+ .wrap { max-width:760px; margin:0 auto; padding:28px 18px 80px; }
12
+ header { display:flex; align-items:baseline; gap:10px; margin-bottom:6px; }
13
+ header h1 { font-size:22px; margin:0; }
14
+ header .sub { color:var(--dim); font-size:13px; }
15
+ .ask { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px; margin:18px 0; }
16
+ .ask textarea { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:10px; padding:12px; font:inherit; resize:vertical; min-height:64px; }
17
+ .row { display:flex; gap:10px; align-items:center; margin-top:10px; }
18
+ select, button { font:inherit; }
19
+ select { background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:8px; }
20
+ button { background:var(--accent); color:#08101f; border:0; border-radius:8px; padding:9px 16px; font-weight:600; cursor:pointer; }
21
+ button.ghost { background:transparent; color:var(--dim); border:1px solid var(--line); }
22
+ button:disabled { opacity:.5; cursor:default; }
23
+ .spacer { flex:1; }
24
+ .card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:16px; margin:14px 0; }
25
+ .goal { font-weight:600; margin-bottom:8px; }
26
+ .status { display:inline-block; font-size:12px; padding:2px 10px; border-radius:20px; border:1px solid var(--line); color:var(--dim); }
27
+ .status.running { color:var(--accent); border-color:var(--accent); }
28
+ .status.needs { color:var(--warn); border-color:var(--warn); }
29
+ .status.done { color:var(--ok); border-color:var(--ok); }
30
+ .status.error { color:#f08a8a; border-color:#f08a8a; }
31
+ .activity { color:var(--dim); font-size:13px; margin-top:8px; min-height:20px; }
32
+ .phase { color:var(--accent); font-size:13px; margin-bottom:5px; }
33
+ .step { font-size:13px; color:var(--dim); padding:1px 0; }
34
+ .step.cur { color:var(--fg); }
35
+ .step.think { color:var(--warn); font-style:italic; }
36
+ .todos { margin-top:10px; padding-top:8px; border-top:1px solid var(--line); }
37
+ .todo { font-size:13px; padding:2px 0; color:var(--dim); }
38
+ .todo.in_progress { color:var(--accent); font-weight:600; }
39
+ .todo.completed { color:var(--ok); }
40
+ button.cancel { margin-left:10px; padding:3px 12px; font-size:12px; background:transparent; color:#f08a8a; border:1px solid #5b3030; }
41
+ button.cancel:hover { background:#2a1818; }
42
+ .dots::after { content:""; animation:dots 1.4s steps(4,end) infinite; }
43
+ @keyframes dots { 0%{content:""} 25%{content:"·"} 50%{content:"··"} 75%{content:"···"} }
44
+ .summary { margin-top:10px; white-space:pre-wrap; }
45
+ .files { margin-top:10px; }
46
+ .file { display:inline-block; margin:3px 6px 0 0; padding:4px 10px; background:#0c0e12; border:1px solid var(--line); border-radius:8px; font-size:13px; cursor:pointer; color:var(--accent); }
47
+ .file.mod { color:var(--warn); }
48
+ .qbox { margin-top:12px; padding:12px; background:#1d1c12; border:1px solid var(--warn); border-radius:10px; }
49
+ .qbox .q { color:var(--warn); margin-bottom:8px; }
50
+ .qbox input { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px; font:inherit; }
51
+ h3 { color:var(--dim); font-size:13px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; margin:28px 0 8px; }
52
+ .hist { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:11px 14px; margin:8px 0; cursor:pointer; }
53
+ .hist:hover { border-color:var(--accent); }
54
+ .hist .g { font-size:14px; } .hist .m { color:var(--dim); font-size:12px; }
55
+ pre.viewer { background:#0c0e12; border:1px solid var(--line); border-radius:10px; padding:12px; overflow:auto; max-height:360px; white-space:pre-wrap; font:13px/1.5 ui-monospace,Menlo,monospace; }
56
+ .empty { color:var(--dim); font-size:13px; }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <div class="wrap">
61
+ <header>
62
+ <h1>🪄 xitto 許願台</h1>
63
+ <span class="sub">說出你想完成的事,交給它去做、做完給你成品</span>
64
+ </header>
65
+
66
+ <div class="ask">
67
+ <textarea id="goal" placeholder="例如:把這個資料夾的 .md 檔整理成一份目錄 index.md;或:抓 example.com 的標題寫進 title.txt"></textarea>
68
+ <div class="row">
69
+ <select id="pack" title="領域"></select>
70
+ <span class="spacer"></span>
71
+ <button id="go">交辦 →</button>
72
+ </div>
73
+ </div>
74
+
75
+ <div id="current"></div>
76
+
77
+ <h3>歷史成品</h3>
78
+ <div id="history"><div class="empty">還沒有任何任務。上面交辦一件事試試。</div></div>
79
+ </div>
80
+
81
+ <script>
82
+ const TOKEN = "__SERVER_TOKEN__";
83
+ const PACKS = __PACKS__;
84
+ const $ = (s) => document.querySelector(s);
85
+ const api = (p, opts={}) => fetch(p, { ...opts, headers: { authorization:"Bearer "+TOKEN, "content-type":"application/json", ...(opts.headers||{}) } });
86
+ const esc = (s) => String(s==null?"":s).replace(/[&<>]/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
87
+
88
+ // 把工具動作翻成人話,讓「進行中」變成看得懂的進度
89
+ const TOOL_ZH = { read:"讀取檔案", ls:"查看目錄", glob:"尋找檔案", grep:"搜尋內容", write:"建立檔案", edit:"修改檔案", bash:"執行指令", web_search:"搜尋網路", web_fetch:"讀取網頁", http:"呼叫 API", read_image:"看圖片", skill:"載入技能", skill_save:"記錄技能", skills_check:"複查技能", playbook_update:"記下做法", episode_record:"記下經驗", episode_recall:"回想經驗", memory_save:"記住重點", todo_write:"規劃步驟", spawn_agent:"派出子助手", ask_user:"來問你" };
90
+ const PHASE_ZH = { starting:"啟動中", thinking:"思考中", acting:"執行中", verifying:"驗收中", verified:"驗收通過", fixing:"修正中", "needs-input":"等你回答" };
91
+ const friendlyStep = (a) => { const z = TOOL_ZH[a.name] || a.name; const d = a.args && (a.args.command||a.args.path||a.args.query||a.args.url||a.args.topic||a.args.name) || ""; return z + (d ? ` ${String(d).slice(0,44)}` : ""); };
92
+ const elapsedOf = (t) => t.startedAt ? Math.max(0, Math.round((Date.now() - new Date(t.startedAt).getTime())/1000)) : 0;
93
+ function progressHtml(t) {
94
+ const p = t.progress || {};
95
+ const head = `${PHASE_ZH[p.phase]||"進行中"}${p.round?` · 第 ${p.round} 輪`:""}${p.steps?` · ${p.steps} 個動作`:""} · 已進行 <span id="elapsed">${elapsedOf(t)}s</span>`;
96
+ const think = p.thinking ? `<div class="step think">💭 ${esc(p.thinking)}…</div>` : "";
97
+ const steps = (p.recent||[]).slice().reverse().map((a,i)=>`<div class="step ${i===0?"cur":""}">${i===0?"▸":"·"} ${esc(friendlyStep(a))}</div>`).join("");
98
+ return `<div class="activity"><div class="phase dots">${head}</div>${think}${steps}</div>`;
99
+ }
100
+ // 心跳:每秒更新「已進行 Ns」,即使沒有新事件也讓使用者看到它活著
101
+ let liveTask = null;
102
+ setInterval(() => { if (liveTask && (liveTask.status==="running"||liveTask.status==="queued")) { const el=document.getElementById("elapsed"); if (el) el.textContent = elapsedOf(liveTask)+"s"; } }, 1000);
103
+
104
+ // 領域選單(非技術使用者預設「通用」)
105
+ const LABELS = { general:"通用", coding:"程式", "data-query":"查資料", notes:"筆記", "deep-research":"研究", devops:"維運" };
106
+ $("#pack").innerHTML = PACKS.map(p=>`<option value="${p}" ${p==="general"?"selected":""}>${LABELS[p]||p}</option>`).join("");
107
+
108
+ let activeId = null, polling = null;
109
+
110
+ $("#go").onclick = async () => {
111
+ const goal = $("#goal").value.trim(); if (!goal) return;
112
+ $("#go").disabled = true;
113
+ const r = await api("/v1/tasks", { method:"POST", body: JSON.stringify({ pack:$("#pack").value, mode:"goal", goal }) }).then(r=>r.json());
114
+ $("#go").disabled = false;
115
+ if (r.error) { alert(r.error); return; }
116
+ $("#goal").value = "";
117
+ activeId = r.taskId;
118
+ poll();
119
+ };
120
+
121
+ function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled")?"error":""; }
122
+ function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷"})[s]||s; }
123
+ const CANCELLABLE = ["queued","running","needs-input"];
124
+ function todosHtml(p){ if(!p||!(p.todos||[]).length) return ""; const ic=s=>s==="completed"?"☑":s==="in_progress"?"◐":"☐"; return `<div class="todos">${p.todos.map(td=>`<div class="todo ${td.status}">${ic(td.status)} ${esc(td.content)}</div>`).join("")}</div>`; }
125
+ async function cancelTask(id){ await api("/v1/tasks/"+id+"/cancel",{method:"POST"}); for(let i=0;i<10;i++){ await new Promise(r=>setTimeout(r,600)); const t=await api("/v1/tasks/"+id).then(r=>r.json()); liveTask=t; renderCurrent(t); if(["done","error","cancelled"].includes(t.status)){loadHistory();break;} } }
126
+
127
+ async function poll() {
128
+ clearTimeout(polling);
129
+ const t = await api("/v1/tasks/"+activeId).then(r=>r.json());
130
+ liveTask = t;
131
+ renderCurrent(t);
132
+ if (t.status==="done" || t.status==="error") { loadHistory(); return; }
133
+ if (t.status==="needs-input") return; // 等使用者回答
134
+ polling = setTimeout(poll, 1200);
135
+ }
136
+
137
+ function renderCurrent(t) {
138
+ const a = t.result?.artifacts, files = a ? [...(a.created||[]).map(f=>[f,"new"]), ...(a.modified||[]).map(f=>[f,"mod"])] : [];
139
+ $("#current").innerHTML = `<div class="card">
140
+ <div class="goal">${esc(t.goal||"任務")}</div>
141
+ <span class="status ${statusClass(t.status)}">${statusText(t.status)}${t.rounds?` · ${t.rounds} 輪`:""}</span>
142
+ ${CANCELLABLE.includes(t.status)?`<button class="cancel" onclick="cancelTask('${t.taskId}')">停止</button>`:""}
143
+ ${t.status==="running"||t.status==="queued"?progressHtml(t):""}
144
+ ${todosHtml(t.progress)}
145
+ ${t.status==="needs-input"?`<div class="qbox"><div class="q">❓ ${esc(t.pending?.question)}</div>
146
+ <input id="ans" placeholder="輸入你的回答,按 Enter 送出"></div>`:""}
147
+ ${t.status==="done"?`<div class="summary">${esc(t.result?.text||"")}</div>`:""}
148
+ ${t.status==="error"?`<div class="summary">⚠ ${esc(t.error)}</div>`:""}
149
+ ${files.length?`<div class="files">📦 成品:${files.map(([f,k])=>`<span class="file ${k==="mod"?"mod":""}" onclick="viewFile('${t.taskId}','${esc(f)}')">${k==="mod"?"~":"+"} ${esc(f)}</span>`).join("")}</div>`:""}
150
+ <pre class="viewer" id="fview" style="display:none"></pre>
151
+ </div>`;
152
+ if (t.status==="needs-input") {
153
+ const inp = $("#ans"); inp.focus();
154
+ inp.onkeydown = async (e) => { if (e.key==="Enter" && inp.value.trim()) {
155
+ const ans = inp.value.trim(); inp.disabled = true;
156
+ await api("/v1/tasks/"+t.taskId+"/answer", { method:"POST", body: JSON.stringify({ answer: ans }) });
157
+ poll();
158
+ }};
159
+ }
160
+ }
161
+
162
+ async function viewFile(id, path) {
163
+ const v = $("#fview"); v.style.display="block"; v.textContent="載入中…";
164
+ const r = await api("/v1/tasks/"+id+"/file?path="+encodeURIComponent(path));
165
+ v.textContent = r.ok ? await r.text() : "(無法讀取)";
166
+ }
167
+
168
+ async function loadHistory() {
169
+ const r = await api("/v1/tasks").then(r=>r.json());
170
+ const list = (r.tasks||[]).filter(t=>t.mode==="goal").reverse();
171
+ $("#history").innerHTML = list.length ? list.map(t=>`<div class="hist" onclick="openTask('${t.taskId}')">
172
+ <div class="g">${esc(t.goal||t.taskId)} <span class="status ${statusClass(t.status)}">${statusText(t.status)}</span></div>
173
+ <div class="m">${esc(t.createdAt)}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
174
+ }
175
+ async function openTask(id){ activeId=id; const t=await api("/v1/tasks/"+id).then(r=>r.json()); liveTask=t; renderCurrent(t); if(t.status==="running"||t.status==="queued"){poll();} window.scrollTo({top:0,behavior:"smooth"}); }
176
+
177
+ loadHistory();
178
+ </script>
179
+ </body>
180
+ </html>
@@ -2,8 +2,8 @@
2
2
  // 確定性那半部:pack 載入、工具註冊、mutatingTools 推導、固定順序守衛鏈、systemPrompt 組裝、
3
3
  // 單一工具呼叫(runTool)。LLM 那半部:runTurn 驅動移植自 xitto-code 的 Agent loop
4
4
  // (串流 + 多步工具循環 + 守衛接線)。壓縮/TUI 仍為後續接縫。
5
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
6
- import { join, dirname, isAbsolute } from 'node:path';
5
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'node:fs';
6
+ import { join, dirname, isAbsolute, relative } from 'node:path';
7
7
  import { loadPack } from './pack-loader.js';
8
8
  import { createToolRegistry, deriveMutatingTools, isSandboxable } from './tool-registry.js';
9
9
  import { composeGuards } from './guard-chain.js';
@@ -44,6 +44,25 @@ function loadContextFiles(cwd, names) {
44
44
  found.map((f) => `## ${f.name}\n${f.text.trim()}`).join('\n\n');
45
45
  }
46
46
 
47
+ // 交付物偵測:掃工作目錄前後快照,diff 出「產出/改動的檔案」(pack 無關,連 bash 寫的也抓得到)。
48
+ const SKIP_SCAN = new Set(['.xitto-kernel', 'node_modules', '.git', '.swebench-repos', '.xitto-server']);
49
+ function scanWorkdir(dir, base = dir, acc = new Map(), depth = 0) {
50
+ if (depth > 8 || acc.size > 5000) return acc;
51
+ let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
52
+ for (const e of entries) {
53
+ if (SKIP_SCAN.has(e.name)) continue;
54
+ const full = join(dir, e.name);
55
+ if (e.isDirectory()) scanWorkdir(full, base, acc, depth + 1);
56
+ else if (e.isFile()) { try { const s = statSync(full); acc.set(relative(base, full), `${s.mtimeMs}:${s.size}`); } catch { /* 略 */ } }
57
+ }
58
+ return acc;
59
+ }
60
+ function diffWorkdir(before, after) {
61
+ const created = [], modified = [];
62
+ for (const [rel, sig] of after) { if (!before.has(rel)) created.push(rel); else if (before.get(rel) !== sig) modified.push(rel); }
63
+ return { created: created.sort(), modified: modified.sort() };
64
+ }
65
+
47
66
  const DEFAULT_MEMORY_GUIDE =
48
67
  '遇到值得跨 session 記住的事實(使用者偏好、踩過的坑、專案決策)時,當下就存一條。';
49
68
 
@@ -153,6 +172,19 @@ export function createKernel(pack, config = {}) {
153
172
  };
154
173
  const skills = createSkills(join(dataDir, 'skills'), { verifyRunner: runVerify }); // 漸進揭露 + 結晶(須驗證)
155
174
 
175
+ // 澄清通道:app 提供 askUser 才有 ask_user 工具(結果導向:自主完成,只在非問不可時才打斷使用者)。
176
+ const askUserTool = typeof config.askUser === 'function' ? {
177
+ name: 'ask_user', label: '詢問使用者', readOnly: true,
178
+ description: '只在「缺少關鍵資訊、無法合理推斷、或決策會明顯改變結果」時,才向使用者提問並等待回答。能自己判斷、能用合理預設就別問——盡量自主完成,把打斷降到最低。回傳使用者的回答。',
179
+ parameters: { type: 'object', properties: { question: { type: 'string', description: '簡短、具體的問題' }, options: { type: 'array', items: { type: 'string' }, description: '可選:提供幾個選項供使用者挑' } }, required: ['question'] },
180
+ execute: async (_id, { question, options }) => {
181
+ let answer; try { answer = await config.askUser({ question, options }); } catch { answer = null; }
182
+ const text = (answer == null || answer === '') ? '(使用者未回答;請用合理預設繼續,不要再追問)' : String(answer);
183
+ // 同時回傳 question + 明確指示,讓模型把回答當權威依據(即使對話很長也不會脫鉤)
184
+ return { content: [{ type: 'text', text: JSON.stringify({ question: String(question || ''), answer: text, note: '這是使用者對你提問的回答,請以此為準繼續,不要忽略或再問同一件事。' }) }] };
185
+ },
186
+ } : null;
187
+
156
188
  // 工具:pack 工具(sandboxable 包 Seatbelt、mutating+path 加 undo 快照)+ kernel 內建記憶工具 + spawn_agent。
157
189
  const undoStack = [];
158
190
  const baseTools = [
@@ -160,6 +192,7 @@ export function createKernel(pack, config = {}) {
160
192
  ...memory.tools,
161
193
  ...playbook.tools,
162
194
  ...episodes.tools,
195
+ ...(askUserTool ? [askUserTool] : []),
163
196
  todo.tool,
164
197
  ...skills.tools,
165
198
  ...(config.extraTools || []), // 外部注入(MCP 工具等):由 app 層先 async 載入再傳入
@@ -190,6 +223,7 @@ export function createKernel(pack, config = {}) {
190
223
  '\n\n# 記憶與專案手冊\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) + '\n' + DEFAULT_PLAYBOOK_GUIDE + '\n' + DEFAULT_EPISODE_GUIDE +
191
224
  (memText ? `\n\n# 已記住的事實(跨 session)\n${memText}` : '') +
192
225
  (pbText ? `\n\n# 專案手冊(這個專案怎麼做事,跨 session 累積)\n${pbText}` : '') +
226
+ (askUserTool ? '\n\n# 詢問\n盡量自主完成目標。只在缺少關鍵資訊、無法合理推斷、或決策會明顯改變結果時,才用 ask_user 問使用者;能用合理預設就別問。' : '') +
193
227
  skills.promptSection();
194
228
 
195
229
  const getPlanMode = config.getPlanMode || (() => false);
@@ -415,6 +449,22 @@ export function createKernel(pack, config = {}) {
415
449
  }
416
450
  return { done: false, maxedOut: true, rounds: maxRounds, history };
417
451
  },
452
+
453
+ /**
454
+ * 結果導向:給目標 → 跑 goal loop → 回「交付物」(做了什麼 + 產出/改動的檔案 + 是否達成)。
455
+ * 對非技術使用者:對話只是過程,這回傳的才是產品。
456
+ * @param {string} goal
457
+ * @param {object} [opts] 同 runGoal
458
+ * @returns {Promise<{ goal, done, rounds, aborted, stalled, summary, artifacts: {created:string[], modified:string[]}, history }>}
459
+ */
460
+ runOutcome: async (goal, opts = {}) => {
461
+ const before = scanWorkdir(cwd);
462
+ const g = await api.runGoal(goal, opts);
463
+ const artifacts = diffWorkdir(before, scanWorkdir(cwd));
464
+ const lastAssistant = [...(g.history || [])].reverse().find((m) => m.role === 'assistant');
465
+ const summary = (lastAssistant?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('').trim();
466
+ return { goal, done: !!g.done, rounds: g.rounds, aborted: !!g.aborted, stalled: !!g.stalled, summary, artifacts, history: g.history };
467
+ },
418
468
  };
419
469
  return api;
420
470
  }