xitto-kernel 0.6.1 → 0.8.4

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,88 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.4
4
+
5
+ - **桌面雙欄佈局(善用寬螢幕)**:原本單條 760px 窄欄、左右大量留白。改用 CSS grid 雙欄:
6
+ - **許願頁** = 主區(許願輸入 + 當前任務/成品)+ **歷史側欄**(sticky,捲動主區時歷史保持可見)
7
+ - **工作台** = 檔案瀏覽器在左、**檔案預覽在右**(並排,不必上下捲)
8
+ - 容器加寬到 1180px;` (max-width:860px)` 自動收成單欄(窄螢幕/手機不變)
9
+ - 純 CSS/HTML 結構調整;測試 188/188,內嵌 JS 語法檢查 + 服務頁面結構驗證通過
10
+
11
+ ## 0.8.3
12
+
13
+ - **工作台改逐層瀏覽(不一次攤平整個專案)**:原本 `listWorkspaceFiles` 會遞迴把所有檔案攤成一長串,對真實專案太雜。改成像檔案總管:只列當前目錄的子資料夾+檔案,點資料夾才進去,有「上一層」。
14
+ - 新增 `listDir(wsDir, sub)`(列單層,排除內部目錄,防穿越);`/v1/workspaces/files` 改吃 `sub=` 逐層
15
+ - 網頁工作台改可導航(麵包屑 + 進子資料夾 + 上一層);切換空間/分頁時重置到根
16
+ - 1 個新測試(listDir 不遞迴/子目錄分開/防穿越)+ 真實 server 端到端(根→src→src/utils)。測試 188/188
17
+
18
+ ## 0.8.2
19
+
20
+ - **`npm run serve:local`**:一行啟動本地就地模式(= `XITTO_SERVER_LOCAL=1 XITTO_SERVER_SANDBOX=off`,token 預設 `secret`、可用 `XITTO_SERVER_TOKEN` 覆寫)。不用每次手打那串環境變數。
21
+
22
+ ## 0.8.1
23
+
24
+ - **資料夾用「選」的(本地模式)**:不用打路徑。
25
+ - 瀏覽器原生選資料夾拿不到絕對路徑(安全限制),改由 **local server 端列資料夾** → 網頁「📁 選資料夾」鈕,從家目錄點進去挑一個
26
+ - 新增 `GET /v1/fs?path=`(**僅本地模式**;列子資料夾,排除 . 開頭/node_modules;託管模式回 403,不洩漏主機結構)
27
+ - 網頁資料夾瀏覽器 modal(上一層/家目錄/選定);空間下拉以 📁 標真實資料夾
28
+ - 1 個新測試(/v1/fs 本地列檔 / 託管 403)+ 真實 server 端到端(家目錄→xiza→選 xitto*)。測試 187/187
29
+
30
+ ## 0.8.0
31
+
32
+ - **本地就地模式(許願台像 Claude Code 改你選的真實資料夾)**:打通「隔離(許願台)」與「就地(Claude Code)」兩個檔案模型。
33
+ - `XITTO_SERVER_LOCAL=1` 時,workspace 可為**真實資料夾的絕對路徑** → 任務**就地改該資料夾的檔**(無隔離副本);網頁「新專案」可貼路徑
34
+ - 新增 `workspaceDir(baseDir, ws, local)`:local + 絕對路徑 → 就地;**否則(含託管收到絕對路徑)→ 消毒成管理空間,不逃逸**
35
+ - 工作台端點改 query `?ws=`(容納絕對路徑);in-place 資料夾不存在 → 400
36
+ - 網頁注入 `__LOCAL__`;空間下拉以 `📁` 標真實資料夾
37
+ - 4 個新測試(workspaceDir 就地/不逃逸/管理)+ 真實 server 端到端
38
+ (local:就地改 /tmp/myproj/calc.js 的 a-b→a+b、無副本;hosted:絕對路徑被消毒不逃逸)。測試 186/186
39
+
40
+ ## 0.7.1
41
+
42
+ - **成品迭代「繼續/調整」(迭代有脈絡)**:在許願台補上迭代閉環。
43
+ - 完成的成品上加「↳ 繼續/調整這個成果」→ 送出**後續任務**,接續這次的對話(`sessionId`)+ 同工作區
44
+ → agent 同時有「**檔案 + 當時的討論與理由**」,不只是檔案
45
+ - 預設每個許願是乾淨新對話(不暴脹);按「繼續」才接續那條線(像 ChatGPT 新對話 vs 接著聊)
46
+ - 任務 view 加 `continued`(帶 sessionId = 接續);歷史以 `↳` 標出接續鏈
47
+ - 底層 sessionId 接續 kernel 本已支援,本版只是接到 UI + 標記
48
+ - 1 個測試(view.continued)+ 真實 server 端到端:任務1 私下說「最不喜歡 Go」(只在對話、不在檔案)→ 後續任務「刪掉我最不喜歡的」→ 正確刪掉 Go(langs.txt 剩 Rust/Zig)。測試 185/185
49
+
50
+ ## 0.7.0
51
+
52
+ - **工作台分頁(看見並管理持久工作區)**:許願台加「許願 | 📂 工作台」**同頁分頁**(不是另開頁面,共用空間/檢視器/認證)。
53
+ - **許願**=交辦任務的主流程(不變);**工作台**=列出當前專案累積的**所有檔案**(看/下載/刪)
54
+ - server 新增 `GET /v1/workspaces/:ws/files`(列檔,排除 .xitto-kernel/tmp/node_modules)、`GET/DELETE /v1/workspaces/:ws/file`(取/刪,防穿越)
55
+ - 檔案檢視器抽為通用 `renderFile(base,…)`,任務成品與工作台共用;`serveFile` 抽出(content-type/下載)
56
+ - 讓持久工作空間從「半成品(檔案累積但看不見)」變成可見可管理;刻意保持輕量(檔案清單,非 IDE)
57
+ - 4 個新測試(safeWs 防穿越 / listWorkspaceFiles 排除內部目錄)+ 真實 server 端到端(兩任務累積→列/取/刪/防穿越)。測試 184/184
58
+
59
+ ## 0.6.4
60
+
61
+ - **許願台「展開過程」+ 彩色 diff(借 Claude Code 的工具卡/⌥展開,翻譯成非技術版)**:
62
+ - 任務 `progress.log` 累積**完整步驟**(人話動作 + 參數摘要 + isError + 編輯的 `diff`);`mapEvent` 的 `tool_end` 帶 `diff`
63
+ - 網頁加「**展開過程(N 步)**」摺疊:預設安靜(只給進度+成品),展開才顯示步驟卡 + **綠 +/紅 - 彩色 diff** → 同畫面服務「只要結果」與「想看細節」兩種人
64
+ - `_diff` 由 kernel 算好(v0.6.3),這版只是把它帶進網頁
65
+ - 3 個新測試(tool_end 帶 diff / log 累積 + diff 補齊)+ 真實 server 端到端(建檔→改檔,改檔步驟帶 `- a-b` / `+ a+b`)。測試 182/182
66
+
67
+ ## 0.6.3
68
+
69
+ - **彩色 diff(編輯一目了然)**:
70
+ - kernel 新增 `diff.js`(LCS 行級 diff);**在 `wrapUndo` 集中計算**——用既有的 undo 快照(before)+ 改後內容(after),
71
+ 把 `_diff` 掛在工具結果上(不進 LLM content,僅供 app 渲染)。**所有 pack 的 edit/write 免改**,二進位/超大檔自動跳過
72
+ - TUI 渲染 `diffBlock`:`⎿ +N -N 行` + 綠 `+` / 紅 `-` 變更行(過長摺疊)
73
+ - 4 個測試(lineDiff 增刪/新檔/超大、diffBlock 渲染、kernel edit 自動掛 _diff)+ 視覺驗證。測試 180/180
74
+ - 註:`_diff` 已在 kernel 算好,日後接到許願台網頁很容易(目前先做 TUI)
75
+
76
+ ## 0.6.2
77
+
78
+ - **TUI 補強(對標 Claude Code 的工具卡)**:
79
+ - **工具卡**:`⏺ name(args)` 標頭 + `⎿` **多行**結果(首行對齊、續行縮排),過長摺疊成「… +N 行」(取代原本單行截斷)
80
+ - **參數摘要人性化**:`bash(npm test)`、`edit(src/a.js)`——取最有意義的參數,不再倒整包 JSON
81
+ - **待辦清單** ☑/◐/☐ 渲染(已有,微調配色)
82
+ - 成功 `⎿ ✓`(綠)/ 失敗 `⎿ ✗`(紅,多顯示幾行)
83
+ - `summarize` / `toolBlock` 抽為純函數並匯出 + 2 個測試。測試 176/176。
84
+ - 仍缺(後續):編輯的彩色 diff(需 edit 工具回傳前後內容)、底部模式/快捷鍵提示列強化
85
+
3
86
  ## 0.6.1
4
87
 
5
88
  - **成品溯源/位置**:分邏輯與實體兩層。
package/README.md CHANGED
@@ -42,7 +42,7 @@ xitto-kernel init
42
42
  **3. 跑內建 pack(互動 CLI)**
43
43
  ```bash
44
44
  xitto-kernel # coding agent(讀寫檔案、跑命令)
45
- xitto-kernel --tui # 完整 Ink TUI(持久狀態列、串流、Esc 中斷;需真實終端)
45
+ xitto-kernel --tui # 完整 Ink TUI(持久狀態列、串流、Esc 中斷、工具卡⏺/⎿、彩色 diff、待辦☑;需真實終端)
46
46
  xitto-kernel --pack notes # 筆記 / 知識庫 agent
47
47
  xitto-kernel --pack data-query
48
48
  xitto-kernel --sandbox # 啟動就開 Seatbelt 沙箱
@@ -96,18 +96,28 @@ const o = await kernel.runOutcome('建立 greet.js 並寫個範例驗證');
96
96
  **🪄 許願台網頁(給非技術使用者:瀏覽器打開就用)**
97
97
  ```bash
98
98
  XITTO_SERVER_TOKEN=secret npm run serve # 然後瀏覽器開 http://localhost:8787/
99
+ # 本地就地模式(可選真實資料夾、就地改檔,沙箱關):
100
+ npm run serve:local # = LOCAL=1 SANDBOX=off,token 預設 secret(可用 XITTO_SERVER_TOKEN 覆寫)
99
101
  ```
100
102
  不用終端機、不用碰金鑰(伺服器端管)。介面以**結果**為中心,不是聊天:
101
103
  - **許願**:打一句「你想完成什麼」→ 交辦(背景跑 goal loop)
102
104
  - **進行中**:**即時進度 + 活著的證明**——每秒跳動的「已進行 Ns」心跳時鐘、目前階段(思考中/執行中/驗收中)、agent 當下的**思考文字**(💭)、工具動作翻成人話、第幾輪 + 動作數。看得到它在想什麼、做什麼
103
105
  - **待辦打勾**:agent 用 `todo_write` 規劃多步任務時,顯示 ☐/◐/☑ 清單,把「未知時長」變成「看得到的剩餘步數」(對標 Claude Code)
104
106
  - **隨時可停**:每個進行中任務有「停止」鈕 → `POST /v1/tasks/:id/cancel`(abort 正在跑的 agent)。控制權在使用者手上,降低「啟動了控制不了的東西」的焦慮
107
+ - **展開過程**:預設安靜(只給進度與成品);想看細節按「展開過程」→ 完整步驟卡(讀/改/跑,人話)+ **編輯的彩色 diff**(綠 +/紅 -)。同一畫面服務「只要結果」與「想看細節」兩種人(對標 Claude Code 的 ⏺/⎿ + ctrl+r 展開)
105
108
  - **需要你回答**:agent 暫停提問時,跳出問題 + 回答框(澄清通道)
106
109
  - **收成品**:完成後顯示摘要 + **產出的檔案**,點檔名可直接看內容(`GET /v1/tasks/:id/file`,防路徑穿越)
110
+ - **繼續/調整(迭代有脈絡)**:成品上有「↳ 繼續/調整這個成果」——打一句想改什麼/想深入什麼,送出一個**後續任務**,**接續這次的對話(sessionId)+ 同工作區**。agent 同時有「檔案 + 當時的討論與理由」,不只是檔案。預設每個許願是乾淨新對話(不暴脹),按「繼續」才接續那條線(像 ChatGPT 開新對話 vs 接著聊)。歷史以 `↳` 標出接續鏈
107
111
  - **歷史成品**:過往交辦的清單(願望 + 狀態),不是聊天串
108
112
 
113
+ **桌面雙欄佈局**:許願頁=主區(許願+當前任務/成品)+ 歷史 sticky 側欄;工作台=檔案瀏覽器左、預覽右並排。容器 1180px,窄螢幕自動收單欄。
114
+
115
+ **工作台分頁(看見並管理你的工作區)**:許願台頂部有「許願 | 📂 工作台」分頁(同頁切換,不是另開頁面)。**許願**=交辦任務的主流程;**工作台**=**逐層瀏覽**當前專案的檔案(像檔案總管:只列當前目錄的子資料夾+檔案,點資料夾才進去,不一次遞迴攤平),看/下載/刪。單一任務用許願就夠;做專案的人切到工作台看「我累積了什麼、拿任意檔、清理」——讓持久工作空間從隱形變成可見可管理。刻意保持輕量(檔案清單,不是 IDE)。
116
+
109
117
  **持久工作空間(成品間的關係)**:每個成品是**獨立的對話**(不續接前一個,避免 context 暴脹),但**共用一個持久工作空間**(`.xitto-server/ws/<workspace>`,預設 `default`)——所以 ① **檔案留存**,後面的任務能接續前面的成果(「把我上次做的 plan.md 翻成英文」);② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——它**越用越懂你**,不再是每次都從零開始的陌生人。`workspace` 可在 POST 時指定(多使用者各自一個);網頁有「專案」下拉切換,每份成品卡標出 `📁 所屬空間`。
110
118
 
119
+ **本地就地模式(像 Claude Code 改你選的真實資料夾)**:`XITTO_SERVER_LOCAL=1` 時,網頁多一個「**📁 選資料夾**」鈕——**用點的**從家目錄瀏覽進你的真實資料夾並選定(不用打路徑;瀏覽器拿不到絕對路徑,所以由 local server 端列資料夾),或「新專案」直接貼絕對路徑也行。任務就**就地改那個資料夾的檔**(不另開隔離副本),工作台列的也是它。這把「許願台(隔離,服務非技術使用者)」和「Claude Code(就地,改你現有的 codebase)」兩個模型打通:**本機自用想就地 → 給路徑;隔離/託管 → 給名稱**。**安全**:只在 `local` 模式才認絕對路徑;**託管模式收到絕對路徑會被消毒成管理空間,不會逃逸到主機任意路徑**。
120
+
111
121
  **溯源/檔案位置**:成品記錄它的**邏輯位置(workspace)**;**實體絕對路徑**預設不外露(託管不洩漏伺服器路徑),只在**本地模式**(`XITTO_SERVER_LOCAL=1`)才在成品附「📂 檔案位置」供你到 Finder/Explorer 找檔。
112
122
 
113
123
  零依賴單一 HTML(`src/app/web/index.html`),polling 不靠 SSE。token 注入頁面供同源呼叫——本地自用零設定;**正式部署請前置真實認證**。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.6.1",
3
+ "version": "0.8.4",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
@@ -42,6 +42,7 @@
42
42
  "demo": "node examples/demo.js",
43
43
  "eval": "node eval/run.js",
44
44
  "serve": "node src/app/server.js",
45
+ "serve:local": "XITTO_SERVER_LOCAL=1 XITTO_SERVER_SANDBOX=off XITTO_SERVER_TOKEN=${XITTO_SERVER_TOKEN:-secret} node src/app/server.js",
45
46
  "start": "node bin/xitto-kernel.js"
46
47
  },
47
48
  "exports": {
package/src/app/server.js CHANGED
@@ -3,9 +3,10 @@
3
3
  // JSON 或 SSE 串流,以及「背景任務 + 完成通知(webhook)」—— 派任務出去、做完回呼,不用一直盯著。
4
4
  // 這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
5
5
  import { createServer } from 'node:http';
6
- import { mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs';
6
+ import { mkdirSync, readFileSync, existsSync, rmSync, readdirSync, statSync } from 'node:fs';
7
7
  import { join, dirname, isAbsolute, relative, basename, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { homedir } from 'node:os';
9
10
  import { createKernel } from '../kernel/index.js';
10
11
  import { loadModel } from './providers.js';
11
12
  import { createCodingPack } from '../packs/coding/index.js';
@@ -27,6 +28,47 @@ const newId = (p = 's') => p + Date.now().toString(36) + Math.random().toString(
27
28
  const MIME = { md: 'text/markdown', markdown: 'text/markdown', txt: 'text/plain', log: 'text/plain', json: 'application/json', csv: 'text/csv', html: 'text/html', htm: 'text/html', js: 'text/javascript', mjs: 'text/javascript', ts: 'text/plain', py: 'text/plain', sh: 'text/plain', css: 'text/css', xml: 'application/xml', yaml: 'text/plain', yml: 'text/plain', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' };
28
29
  export function contentTypeFor(name) { const ext = (String(name).split('.').pop() || '').toLowerCase(); return MIME[ext] || 'application/octet-stream'; }
29
30
 
31
+ // 工具參數摘要(給「展開過程」步驟卡):取最有意義的參數。
32
+ const argSummary = (args) => { if (!args || typeof args !== 'object') return ''; const v = args.command ?? args.path ?? args.pattern ?? args.query ?? args.url ?? args.name ?? args.topic; return (v != null && v !== '') ? String(v).replace(/\s+/g, ' ').slice(0, 80) : ''; };
33
+
34
+ // workspace 名稱消毒(防穿越)。
35
+ export const safeWs = (w) => (String(w || 'default').replace(/[^a-zA-Z0-9_-]/g, '') || 'default');
36
+
37
+ // 解析 workspace → 真實目錄。本地模式 + 絕對路徑 → 就地用該真實資料夾(像 Claude Code);
38
+ // 否則(含託管模式收到絕對路徑)→ 消毒成管理空間 ws/<name>,不會逃逸到主機任意路徑。
39
+ export function workspaceDir(baseDir, ws, local) {
40
+ return (local && isAbsolute(String(ws || ''))) ? String(ws) : join(baseDir, 'ws', safeWs(ws));
41
+ }
42
+
43
+ // 列工作區檔案(給「工作台」分頁):遞迴,排除內部目錄,回 [{path,size,mtime}]。
44
+ const SKIP_WS = new Set(['.xitto-kernel', 'node_modules', '.git', 'tmp', '.swebench-repos']);
45
+ export function listWorkspaceFiles(dir, base = dir, out = [], depth = 0) {
46
+ if (depth > 8 || out.length > 2000) return out;
47
+ let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
48
+ for (const e of entries) {
49
+ if (SKIP_WS.has(e.name)) continue;
50
+ const full = join(dir, e.name);
51
+ if (e.isDirectory()) listWorkspaceFiles(full, base, out, depth + 1);
52
+ else if (e.isFile()) { try { const s = statSync(full); out.push({ path: relative(base, full), size: s.size, mtime: s.mtimeMs }); } catch { /* 略 */ } }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ // 列單一層級(給工作台逐層瀏覽,不一次遞迴攤平整個專案):回 { sub, dirs:[], files:[{name,size,mtime}] }。
58
+ export function listDir(wsDir, sub) {
59
+ const rel = String(sub || '').replace(/^\/+|\/+$/g, '');
60
+ const dir = rel === '' ? wsDir : resolveArtifact(wsDir, rel);
61
+ if (!dir || !existsSync(dir)) return null;
62
+ const dirs = [], files = [];
63
+ let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return null; }
64
+ for (const e of entries) {
65
+ if (SKIP_WS.has(e.name)) continue;
66
+ if (e.isDirectory()) dirs.push(e.name);
67
+ else if (e.isFile()) { try { const s = statSync(join(dir, e.name)); files.push({ name: e.name, size: s.size, mtime: s.mtimeMs }); } catch { /* 略 */ } }
68
+ }
69
+ return { sub: rel, dirs: dirs.sort(), files: files.sort((a, b) => b.mtime - a.mtime) };
70
+ }
71
+
30
72
  // 交付檔案路徑解析(防穿越):rel 必須是 workdir 內的相對路徑,否則回 null。
31
73
  export function resolveArtifact(workdir, rel) {
32
74
  if (typeof rel !== 'string' || !rel || isAbsolute(rel)) return null;
@@ -41,7 +83,7 @@ const webHtml = () => (_webHtml ??= readFileSync(join(dirname(fileURLToPath(impo
41
83
  // 把原始 kernel 事件壓成精簡的對外事件(串流端與背景任務共用,避免重複映射)
42
84
  export const mapEvent = (ev) => {
43
85
  if (ev.type === 'tool_execution_start') return { type: 'tool', name: ev.toolName, args: ev.args };
44
- if (ev.type === 'tool_execution_end') return { type: 'tool_end', name: ev.toolName, isError: !!ev.isError };
86
+ if (ev.type === 'tool_execution_end') return { type: 'tool_end', name: ev.toolName, isError: !!ev.isError, diff: ev.result?._diff || undefined };
45
87
  if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') return { type: 'text', delta: ev.assistantMessageEvent.delta };
46
88
  if (ev.type === 'round') return { type: 'round', round: ev.round, maxRounds: ev.maxRounds };
47
89
  if (ev.type === 'verify_start') return { type: 'phase', phase: 'verifying' };
@@ -64,15 +106,22 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
64
106
  const subs = new Map(); // id -> Set<(ev)=>void>
65
107
  let active = 0;
66
108
 
67
- const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', workspace: t.spec.workspace || 'default', 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 });
109
+ const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', workspace: t.spec.workspace || 'default', goal: t.spec.goal || t.spec.input || '', sessionId: t.result?.sessionId || t.spec.sessionId || null, continued: !!t.spec.sessionId, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error, pending: t.pending || null, progress: t.progress || null });
68
110
 
69
111
  const emit = (t, ev) => {
70
112
  t.events.push(ev);
71
113
  if (t.events.length > maxEvents) t.events.shift();
72
114
  // 進度追蹤(給 UI 顯示「正在做什麼」,不要只顯示進行中;排除 text 雜訊)
73
- const p = (t.progress ||= { steps: 0, round: 0, maxRounds: 0, recent: [], phase: 'starting', thinking: '', todos: [] });
115
+ const p = (t.progress ||= { steps: 0, round: 0, maxRounds: 0, recent: [], phase: 'starting', thinking: '', todos: [], log: [] });
74
116
  if (ev.type === 'tool' && ev.name === 'todo_write') { if (Array.isArray(ev.args?.todos)) p.todos = ev.args.todos; } // 待辦清單(給 UI 打勾)
75
- 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(); }
117
+ else if (ev.type === 'tool') {
118
+ p.steps++; p.phase = 'acting'; p.thinking = ''; t._textbuf = '';
119
+ p.recent.push({ name: ev.name, args: ev.args }); if (p.recent.length > 6) p.recent.shift();
120
+ if (p.log.length < 100) p.log.push({ name: ev.name, summary: argSummary(ev.args) }); // 完整步驟(給「展開過程」)
121
+ } else if (ev.type === 'tool_end') {
122
+ const last = p.log[p.log.length - 1];
123
+ if (last && last.name === ev.name && !('isError' in last)) { last.isError = ev.isError; if (ev.diff) last.diff = ev.diff; }
124
+ }
76
125
  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); }
77
126
  else if (ev.type === 'round') { p.round = ev.round; if (ev.maxRounds) p.maxRounds = ev.maxRounds; p.thinking = ''; t._textbuf = ''; }
78
127
  else if (ev.type === 'phase') p.phase = ev.phase;
@@ -172,6 +221,17 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
172
221
  const log = (o) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...o }));
173
222
  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({}); } }); });
174
223
  const sseHead = (res) => res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
224
+ // 依副檔名給 content-type 回傳檔案(圖片顯示/md 渲染/下載皆走這)。
225
+ const serveFile = (res, full, rel, download) => {
226
+ if (!existsSync(full)) return json(res, 404, { error: '檔案不存在' });
227
+ try {
228
+ const ct = contentTypeFor(rel);
229
+ const isText = /^text\/|json|xml|javascript|svg/.test(ct);
230
+ const headers = { 'content-type': ct + (isText ? '; charset=utf-8' : '') };
231
+ if (download) headers['content-disposition'] = `attachment; filename="${encodeURIComponent(basename(rel))}"`;
232
+ res.writeHead(200, headers); return res.end(readFileSync(full));
233
+ } catch (e) { return json(res, 500, { error: e.message }); }
234
+ };
175
235
 
176
236
  // 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件;
177
237
  // ask(可選)= 澄清通道,讓 agent 在背景任務中暫停問使用者。
@@ -179,8 +239,9 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
179
239
  const make = PACKS[spec.pack || 'general'];
180
240
  if (!make) throw new Error(`未知 pack「${spec.pack}」,可用:${Object.keys(PACKS).join(', ')}`);
181
241
  // 持久工作空間(B 模型):workdir 綁 workspace(非 sessionId)→ 檔案留存 + 五層沉澱跨成品累積。
182
- const workspace = (spec.workspace || 'default').replace(/[^a-zA-Z0-9_-]/g, '') || 'default';
183
- const workdir = join(baseDir, 'ws', workspace); mkdirSync(workdir, { recursive: true });
242
+ // 本地模式 + workspace 是絕對路徑 就地用該真實資料夾(像 Claude Code 改你現有的檔)。
243
+ const workspace = spec.workspace || 'default';
244
+ const workdir = workspaceDir(baseDir, workspace, local); mkdirSync(workdir, { recursive: true });
184
245
  // history 仍綁 sessionId(每個成品獨立對話:無 sessionId → 全新,不續接,避免 context 暴脹/混淆)
185
246
  const sessionId = spec.sessionId || newId();
186
247
  const sess = sessions.get(sessionId) || { history: [] };
@@ -224,7 +285,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
224
285
  if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
225
286
  let html; try { html = webHtml(); } catch { return json(res, 500, { error: 'web UI 未找到' }); }
226
287
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
227
- return res.end(html.replace(/__SERVER_TOKEN__/g, token || '').replace(/__PACKS__/g, JSON.stringify(Object.keys(PACKS))));
288
+ return res.end(html.replace(/__SERVER_TOKEN__/g, token || '').replace(/__PACKS__/g, JSON.stringify(Object.keys(PACKS))).replace(/__LOCAL__/g, local ? 'true' : 'false'));
228
289
  }
229
290
 
230
291
  if (!authed(req)) return json(res, 401, { error: 'unauthorized(帶 Authorization: Bearer <token>)' });
@@ -252,6 +313,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
252
313
  const body = await readBody(req);
253
314
  if (!PACKS[body.pack || 'general']) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
254
315
  if (body.webhook && !/^https?:\/\//.test(body.webhook)) return json(res, 400, { error: 'webhook 需為 http(s) URL' });
316
+ if (local && body.workspace && isAbsolute(body.workspace) && !existsSync(body.workspace)) return json(res, 400, { error: `資料夾不存在:${body.workspace}` });
255
317
  const t = tasks.enqueue({ pack: body.pack, mode: body.mode, input: body.input, goal: body.goal, sessionId: body.sessionId, webhook: body.webhook, workspace: body.workspace });
256
318
  log({ task: t.id, action: 'enqueue', pack: body.pack || 'general', mode: body.mode || 'turn' });
257
319
  return json(res, 202, { taskId: t.id, status: t.status, ...tasks.stats() });
@@ -288,16 +350,37 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
288
350
  const t = tasks.get(mFile[1]); const ws = t?.result?.workspace;
289
351
  if (!ws) return json(res, 404, { error: '無交付物(任務尚未完成?)' });
290
352
  const rel = url.searchParams.get('path');
291
- const full = resolveArtifact(join(baseDir, 'ws', ws), rel);
353
+ const full = resolveArtifact(workspaceDir(baseDir, ws, local), rel);
292
354
  if (!full) return json(res, 400, { error: 'path 不合法' });
293
- if (!existsSync(full)) return json(res, 404, { error: '檔案不存在' });
355
+ return serveFile(res, full, rel, url.searchParams.get('download'));
356
+ }
357
+
358
+ // 資料夾瀏覽器(僅本地模式):列某路徑下的子資料夾,給網頁「用選的」挑真實資料夾
359
+ if (req.method === 'GET' && path === '/v1/fs') {
360
+ if (!local) return json(res, 403, { error: '僅本地模式可瀏覽資料夾' });
361
+ const dir = resolve(url.searchParams.get('path') || homedir());
294
362
  try {
295
- const ct = contentTypeFor(rel);
296
- const isText = /^text\/|json|xml|javascript|svg/.test(ct);
297
- const headers = { 'content-type': ct + (isText ? '; charset=utf-8' : '') };
298
- if (url.searchParams.get('download')) headers['content-disposition'] = `attachment; filename="${encodeURIComponent(basename(rel))}"`;
299
- res.writeHead(200, headers); return res.end(readFileSync(full));
300
- } catch (e) { return json(res, 500, { error: e.message }); }
363
+ const dirs = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.')).map((e) => e.name).sort();
364
+ return json(res, 200, { path: dir, parent: dirname(dir), home: homedir(), dirs });
365
+ } catch (e) { return json(res, 400, { error: '無法讀取:' + e.message }); }
366
+ }
367
+
368
+ // 工作台:逐層列檔(sub=子目錄,不一次遞迴攤平整個專案;ws query 以容納本地絕對路徑)
369
+ if (req.method === 'GET' && path === '/v1/workspaces/files') {
370
+ const dir = workspaceDir(baseDir, url.searchParams.get('ws') || 'default', local);
371
+ return json(res, 200, listDir(dir, url.searchParams.get('sub') || '') || { sub: '', dirs: [], files: [] });
372
+ }
373
+ // 工作台:取檔(看/下載)/ 刪檔
374
+ if (path === '/v1/workspaces/file' && (req.method === 'GET' || req.method === 'DELETE')) {
375
+ const dir = workspaceDir(baseDir, url.searchParams.get('ws') || 'default', local);
376
+ const rel = url.searchParams.get('path');
377
+ const full = resolveArtifact(dir, rel);
378
+ if (!full) return json(res, 400, { error: 'path 不合法' });
379
+ if (req.method === 'DELETE') {
380
+ try { if (existsSync(full)) rmSync(full); log({ action: 'delete', path: rel }); return json(res, 200, { ok: true }); }
381
+ catch (e) { return json(res, 500, { error: e.message }); }
382
+ }
383
+ return serveFile(res, full, rel, url.searchParams.get('download'));
301
384
  }
302
385
 
303
386
  // 附掛背景任務的事件流(replay 緩衝 + 即時;已結束則回放後關閉)
@@ -8,8 +8,39 @@ import { createKernel } from '../kernel/index.js';
8
8
  import { createStore, mountTui, gutter } from './tui.js';
9
9
  import { md } from './md-render.js';
10
10
 
11
- const summarize = (args) => { const s = JSON.stringify(args ?? {}); return s.length > 60 ? s.slice(0, 57) + '…' : s; };
12
- const Y = (s) => `\x1b[33m${s}\x1b[39m`; const G = (s) => `\x1b[90m${s}\x1b[39m`; const R = (s) => `\x1b[31m${s}\x1b[39m`; const C = (s) => `\x1b[36m${s}\x1b[39m`;
11
+ // 取最有意義的參數當摘要(像 Claude Code:bash(npm test) 而非 bash({"command":...})
12
+ export const summarize = (args) => {
13
+ if (!args || typeof args !== 'object') return '';
14
+ const v = args.command ?? args.path ?? args.pattern ?? args.query ?? args.url ?? args.name ?? args.topic ?? args.file;
15
+ if (v != null && v !== '') return String(v).replace(/\s+/g, ' ').slice(0, 60);
16
+ const s = JSON.stringify(args); return s === '{}' ? '' : (s.length > 60 ? s.slice(0, 57) + '…' : s);
17
+ };
18
+ const Y = (s) => `\x1b[33m${s}\x1b[39m`; const G = (s) => `\x1b[90m${s}\x1b[39m`; const R = (s) => `\x1b[31m${s}\x1b[39m`; const C = (s) => `\x1b[36m${s}\x1b[39m`; const Gn = (s) => `\x1b[32m${s}\x1b[39m`;
19
+
20
+ // 工具卡(對標 Claude Code):⏺ name(args) 標頭 + ⎿ 多行結果,過長摺疊成「… +N 行」。純函數,可測。
21
+ export function toolBlock(name, summary, result, isError) {
22
+ const head = Y(`⏺ ${name}`) + (summary ? G(`(${summary})`) : '');
23
+ const raw = (result?.content || []).map((c) => c.text || '').join('\n').replace(/\s+$/, '');
24
+ if (!raw.trim()) return head + '\n' + (isError ? R(' ⎿ ✗') : Gn(' ⎿ ✓'));
25
+ const lines = raw.split('\n');
26
+ const MAX = isError ? 12 : 6;
27
+ const shown = lines.slice(0, MAX).map((l, i) => ' ' + (i === 0 ? '⎿ ' : ' ') + l.slice(0, 200));
28
+ let out = head + '\n' + (isError ? R : G)(shown.join('\n'));
29
+ if (lines.length > MAX) out += '\n' + G(` … +${lines.length - MAX} 行`);
30
+ return out;
31
+ }
32
+
33
+ // 彩色 diff 區塊(綠 + / 紅 -):渲染 kernel 掛在 result._diff 的行級 diff。
34
+ export function diffBlock(d) {
35
+ if (!d) return '';
36
+ const head = G(' ⎿ ') + Gn(`+${d.added}`) + ' ' + R(`-${d.removed}`) + G(' 行');
37
+ if (d.tooBig) return head + G('(差異過大,省略內容)');
38
+ const changed = (d.lines || []).filter((l) => l.t !== ' ');
39
+ if (!changed.length) return '';
40
+ const MAX = 30;
41
+ const body = changed.slice(0, MAX).map((l) => (l.t === '+' ? Gn(' + ' + l.s.slice(0, 200)) : R(' - ' + l.s.slice(0, 200)))).join('\n');
42
+ return head + '\n' + body + (changed.length > MAX ? '\n' + G(` … +${changed.length - MAX} 行變更`) : '');
43
+ }
13
44
 
14
45
  const SLASH = { '/help': '說明', '/goal': '目標循環', '/sandbox': '沙箱', '/auto': '自動核准', '/plan': '計劃模式', '/undo': '撤銷', '/tools': '工具', '/memory': '記憶', '/sessions': '對話', '/resume': '續接', '/cost': '成本', '/clear': '清除', '/exit': '離開' };
15
46
 
@@ -48,10 +79,7 @@ export function runTui({ pack, model, getApiKey, sandbox = false, resume = null
48
79
  const persist = () => { try { kernel.session.save(sessionId, history); } catch { /* 略 */ } };
49
80
 
50
81
  // ── kernel 事件 → store ──
51
- const toolBlock = (name, result, isError) => {
52
- const text = (result?.content || []).map((c) => c.text || '').join(' ').replace(/\s+/g, ' ').trim();
53
- return Y(`⏺ ${name}`) + '\n' + (isError ? R(' ⎿ ✗ ' + text.slice(0, 200)) : G(' ⎿ ✓ ' + text.slice(0, 120)));
54
- };
82
+ let pendingSummary = '';
55
83
  const onEvent = (ev) => {
56
84
  switch (ev.type) {
57
85
  case 'message_update': {
@@ -68,15 +96,25 @@ export function runTui({ pack, model, getApiKey, sandbox = false, resume = null
68
96
  case 'tool_execution_start':
69
97
  store.finalizeLive();
70
98
  if (ev.toolName === 'todo_write' && Array.isArray(ev.args?.todos)) {
71
- store.pushBlock(C('☑ 待辦') + '\n' + ev.args.todos.map((t) => ' ' + (t.status === 'completed' ? '\x1b[32m☑\x1b[39m ' + G(t.content) : t.status === 'in_progress' ? Y('◐ ') + t.content : G('☐ ' + t.content))).join('\n'));
99
+ store.pushBlock(C('☑ 待辦') + '\n' + ev.args.todos.map((t) => ' ' + (t.status === 'completed' ? Gn(' ') + G(t.content) : t.status === 'in_progress' ? Y('◐ ') + t.content : G('☐ ' + t.content))).join('\n'));
72
100
  } else {
73
- store.setTool({ name: ev.toolName, summary: summarize(ev.args) });
101
+ pendingSummary = summarize(ev.args);
102
+ store.setTool({ name: ev.toolName, summary: pendingSummary });
74
103
  }
75
104
  break;
76
- case 'tool_execution_end':
105
+ case 'tool_execution_end': {
77
106
  store.setTool(null);
78
- if (ev.toolName !== 'todo_write') store.pushBlock(toolBlock(ev.toolName, ev.result, ev.isError));
107
+ if (ev.toolName !== 'todo_write') {
108
+ const d = ev.result?._diff;
109
+ if (d && !ev.isError && (d.added || d.removed || d.tooBig)) {
110
+ store.pushBlock(Y(`⏺ ${ev.toolName}`) + (pendingSummary ? G(`(${pendingSummary})`) : '') + '\n' + diffBlock(d));
111
+ } else {
112
+ store.pushBlock(toolBlock(ev.toolName, pendingSummary, ev.result, ev.isError));
113
+ }
114
+ }
115
+ pendingSummary = '';
79
116
  break;
117
+ }
80
118
  case 'verify_start': store.finalizeLive(); store.pushBlock(G(' 🔎 自動驗收…')); break;
81
119
  case 'verify_end': store.pushBlock(ev.ok ? G(' ✓ 驗收通過') : Y(' ✗ 驗收失敗,修正中…')); break;
82
120
  case 'compact': store.pushBlock(G(` ⊙ 已壓縮上下文:${ev.tokensBefore}→${ev.tokensAfter} tokens`)); break;
@@ -8,7 +8,14 @@
8
8
  :root { --bg:#0f1115; --card:#181b22; --line:#272b34; --fg:#e7e9ee; --dim:#8b919e; --accent:#6ea8fe; --ok:#5fd38a; --warn:#f0c674; --me:#222630; }
9
9
  * { box-sizing:border-box; }
10
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; }
11
+ .wrap { max-width:1180px; margin:0 auto; padding:24px 22px 80px; }
12
+ /* 雙欄佈局:許願頁=主區+歷史側欄;工作台=檔案左+預覽右。窄螢幕自動收成單欄 */
13
+ .cols { display:grid; grid-template-columns:minmax(0,1fr) 320px; gap:26px; align-items:start; }
14
+ .cols2 { display:grid; grid-template-columns:340px minmax(0,1fr); gap:26px; align-items:start; }
15
+ .side, .wbleft { position:sticky; top:14px; }
16
+ .side h3, .wbleft h3 { margin-top:0; }
17
+ .wbright { position:sticky; top:14px; min-height:120px; }
18
+ @media (max-width:860px){ .cols,.cols2{ grid-template-columns:1fr; } .side,.wbleft,.wbright{ position:static; } }
12
19
  header { display:flex; align-items:baseline; gap:10px; margin-bottom:6px; }
13
20
  header h1 { font-size:22px; margin:0; }
14
21
  header .sub { color:var(--dim); font-size:13px; }
@@ -42,6 +49,15 @@
42
49
  .wsbadge { display:inline-block; font-size:12px; color:var(--dim); margin-left:8px; }
43
50
  .loc { margin-top:10px; font-size:12px; color:var(--dim); }
44
51
  .loc code { background:#0c0e12; border:1px solid var(--line); border-radius:6px; padding:2px 7px; color:var(--fg); cursor:pointer; }
52
+ .logtoggle { margin-top:12px; font-size:13px; color:var(--accent); cursor:pointer; user-select:none; }
53
+ .loglist { margin-top:6px; border-left:2px solid var(--line); padding-left:12px; }
54
+ .logstep { font-size:13px; color:var(--fg); padding:3px 0; }
55
+ .logstep.err { color:#f08a8a; }
56
+ .logstep .dim { color:var(--dim); }
57
+ .diff { margin:5px 0 6px 14px; font:12px/1.5 ui-monospace,Menlo,monospace; }
58
+ .dh { color:var(--dim); font-size:11px; margin-bottom:2px; }
59
+ .dl { white-space:pre-wrap; }
60
+ .dl.add { color:var(--ok); } .dl.del { color:#f08a8a; }
45
61
  .dots::after { content:""; animation:dots 1.4s steps(4,end) infinite; }
46
62
  @keyframes dots { 0%{content:""} 25%{content:"·"} 50%{content:"··"} 75%{content:"···"} }
47
63
  .summary { margin-top:10px; white-space:pre-wrap; }
@@ -56,6 +72,23 @@
56
72
  .hist:hover { border-color:var(--accent); }
57
73
  .hist .g { font-size:14px; } .hist .m { color:var(--dim); font-size:12px; }
58
74
  .empty { color:var(--dim); font-size:13px; }
75
+ .tabs { display:flex; gap:4px; border-bottom:1px solid var(--line); margin:14px 0 4px; }
76
+ .tab { padding:8px 16px; font-size:14px; color:var(--dim); cursor:pointer; border-bottom:2px solid transparent; }
77
+ .tab.active { color:var(--fg); border-bottom-color:var(--accent); }
78
+ .wbrow { display:flex; align-items:center; gap:10px; padding:9px 12px; background:var(--card); border:1px solid var(--line); border-radius:8px; margin:6px 0; }
79
+ .wbname { flex:1; color:var(--accent); cursor:pointer; font-size:14px; }
80
+ .wbmeta { color:var(--dim); font-size:12px; }
81
+ .wbdel { cursor:pointer; opacity:.6; } .wbdel:hover { opacity:1; }
82
+ .followup { margin-top:14px; padding-top:12px; border-top:1px solid var(--line); }
83
+ .followup textarea { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:10px; font:inherit; resize:vertical; min-height:52px; margin-bottom:8px; }
84
+ .cont { color:var(--accent); }
85
+ .modal { position:fixed; inset:0; background:rgba(0,0,0,.6); display:flex; align-items:center; justify-content:center; z-index:10; }
86
+ .modalbox { background:var(--card); border:1px solid var(--line); border-radius:14px; width:min(560px,92vw); max-height:80vh; display:flex; flex-direction:column; padding:14px; }
87
+ .fspath { font:12px ui-monospace,Menlo,monospace; color:var(--dim); margin-bottom:8px; word-break:break-all; }
88
+ .fslist { flex:1; overflow:auto; border:1px solid var(--line); border-radius:8px; }
89
+ .fsrow { padding:8px 12px; cursor:pointer; border-bottom:1px solid var(--line); font-size:14px; }
90
+ .fsrow:hover { background:#0c0e12; } .fsrow.up { color:var(--dim); }
91
+ .fsbar { display:flex; align-items:center; gap:8px; margin-top:10px; }
59
92
  .viewer { margin-top:10px; border:1px solid var(--line); border-radius:10px; padding:12px; background:#0c0e12; }
60
93
  .vbar { font-size:12px; color:var(--dim); margin-bottom:8px; }
61
94
  .vbar a { color:var(--accent); text-decoration:none; }
@@ -75,27 +108,63 @@
75
108
  <span class="sub">說出你想完成的事,交給它去做、做完給你成品</span>
76
109
  <span class="spacer"></span>
77
110
  <select id="space" title="專案/空間:不同專案的檔案與記憶各自獨立"></select>
111
+ <button class="ghost" id="browsebtn" title="瀏覽並選一個真實資料夾(本地模式)" style="display:none">📁 選資料夾</button>
78
112
  <button class="ghost" id="newspace" title="新專案">+</button>
79
113
  </header>
80
114
 
81
- <div class="ask">
82
- <textarea id="goal" placeholder="例如:把這個資料夾的 .md 檔整理成一份目錄 index.md;或:抓 example.com 的標題寫進 title.txt"></textarea>
83
- <div class="row">
84
- <select id="pack" title="領域"></select>
85
- <span class="spacer"></span>
86
- <button id="go">交辦 →</button>
115
+ <div id="fsmodal" class="modal" style="display:none">
116
+ <div class="modalbox">
117
+ <div style="margin-bottom:8px;font-weight:600">選一個資料夾(就地工作)</div>
118
+ <div class="fspath" id="fspath"></div>
119
+ <div class="fslist" id="fslist"></div>
120
+ <div class="fsbar">
121
+ <button class="ghost" onclick="fsHome()">🏠 家目錄</button>
122
+ <button class="ghost" onclick="closeFs()">取消</button>
123
+ <span class="spacer"></span>
124
+ <button onclick="chooseFs()">✓ 選這個資料夾</button>
125
+ </div>
87
126
  </div>
88
127
  </div>
89
128
 
90
- <div id="current"></div>
129
+ <div class="tabs">
130
+ <span class="tab active" id="tab-wish" onclick="switchTab('wish')">許願</span>
131
+ <span class="tab" id="tab-wb" onclick="switchTab('wb')">📂 工作台</span>
132
+ </div>
91
133
 
92
- <h3>歷史成品</h3>
93
- <div id="history"><div class="empty">還沒有任何任務。上面交辦一件事試試。</div></div>
134
+ <div id="wishview" class="cols">
135
+ <div class="main">
136
+ <div class="ask">
137
+ <textarea id="goal" placeholder="例如:把這個資料夾的 .md 檔整理成一份目錄 index.md;或:抓 example.com 的標題寫進 title.txt"></textarea>
138
+ <div class="row">
139
+ <select id="pack" title="領域"></select>
140
+ <span class="spacer"></span>
141
+ <button id="go">交辦 →</button>
142
+ </div>
143
+ </div>
144
+ <div id="current"></div>
145
+ </div>
146
+ <aside class="side">
147
+ <h3>歷史成品</h3>
148
+ <div id="history"><div class="empty">還沒有任何任務。<br>左邊交辦一件事試試。</div></div>
149
+ </aside>
150
+ </div>
151
+
152
+ <div id="workbench" class="cols2" style="display:none">
153
+ <div class="wbleft">
154
+ <h3>📂 工作台</h3>
155
+ <div id="wbfiles"></div>
156
+ </div>
157
+ <div class="wbright">
158
+ <div id="wbhint" class="empty">← 點左邊的檔案,在這裡預覽</div>
159
+ <div class="viewer" id="wbview" style="display:none"></div>
160
+ </div>
161
+ </div>
94
162
  </div>
95
163
 
96
164
  <script>
97
165
  const TOKEN = "__SERVER_TOKEN__";
98
166
  const PACKS = __PACKS__;
167
+ const LOCAL = __LOCAL__; // 本地模式:可把專案指向真實資料夾(就地工作,像 Claude Code)
99
168
  const $ = (s) => document.querySelector(s);
100
169
  const api = (p, opts={}) => fetch(p, { ...opts, headers: { authorization:"Bearer "+TOKEN, "content-type":"application/json", ...(opts.headers||{}) } });
101
170
  const esc = (s) => String(s==null?"":s).replace(/[&<>]/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
@@ -123,11 +192,36 @@ $("#pack").innerHTML = PACKS.map(p=>`<option value="${p}" ${p==="general"?"selec
123
192
  // 專案/空間(對應 Claude Code 的「目錄」,但可選+命名+有預設;不同空間的檔案與沉澱各自獨立)
124
193
  let spaces = JSON.parse(localStorage.getItem("xk_spaces")||'["default"]');
125
194
  let curSpace = localStorage.getItem("xk_space")||"default";
126
- function renderSpaces(){ $("#space").innerHTML = spaces.map(s=>`<option ${s===curSpace?"selected":""}>${esc(s)}</option>`).join(""); }
127
- $("#space").onchange = () => { curSpace=$("#space").value; localStorage.setItem("xk_space",curSpace); $("#current").innerHTML=""; loadHistory(); };
128
- $("#newspace").onclick = () => { const n=(prompt("新專案名稱(英數/底線/連字號):")||"").trim().replace(/[^a-zA-Z0-9_-]/g,""); if(!n)return; if(!spaces.includes(n))spaces.push(n); curSpace=n; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace); renderSpaces(); $("#current").innerHTML=""; loadHistory(); };
195
+ const spaceLabel=s=>s.startsWith("/")?("📁 "+(s.split("/").filter(Boolean).pop()||s)):s;
196
+ function renderSpaces(){ $("#space").innerHTML = spaces.map(s=>`<option value="${esc(s)}" ${s===curSpace?"selected":""}>${esc(spaceLabel(s))}</option>`).join(""); }
197
+ $("#space").onchange = () => { curSpace=$("#space").value; localStorage.setItem("xk_space",curSpace); $("#current").innerHTML=""; loadHistory(); if($("#workbench").style.display!=="none"){ wbSub=""; loadWorkbench(); } };
198
+ $("#newspace").onclick = () => {
199
+ const raw=(prompt(LOCAL?"新專案:輸入名稱,或貼上一個真實資料夾的絕對路徑(本地模式會就地改該資料夾的檔,像 Claude Code)":"新專案名稱(英數/底線/連字號):")||"").trim();
200
+ if(!raw) return;
201
+ const n = (LOCAL && raw.startsWith("/")) ? raw : raw.replace(/[^a-zA-Z0-9_-]/g,"");
202
+ if(!n) return;
203
+ if(!spaces.includes(n)) spaces.push(n);
204
+ curSpace=n; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace);
205
+ renderSpaces(); $("#current").innerHTML=""; loadHistory();
206
+ };
129
207
  renderSpaces();
130
208
 
209
+ // 資料夾瀏覽器(本地模式「用選的」):伺服器端列資料夾,網頁點進去挑一個
210
+ if(LOCAL) $("#browsebtn").style.display="";
211
+ $("#browsebtn").onclick = ()=>{ $("#fsmodal").style.display="flex"; fsGo(null); };
212
+ let fsPath=null;
213
+ async function fsGo(p){
214
+ const r=await api("/v1/fs"+(p?("?path="+encodeURIComponent(p)):"")).then(r=>r.json()).catch(()=>({error:"讀取失敗"}));
215
+ if(r.error){ alert(r.error); return; }
216
+ fsPath=r.path; window._fsHome=r.home;
217
+ $("#fspath").textContent="📂 "+r.path;
218
+ const base=r.path.endsWith("/")?r.path:r.path+"/";
219
+ $("#fslist").innerHTML=`<div class="fsrow up" onclick="fsGo('${esc(r.parent)}')">⬆ 上一層</div>`+(r.dirs.length?r.dirs.map(d=>`<div class="fsrow" onclick="fsGo('${esc(base+d)}')">📁 ${esc(d)}</div>`).join(""):`<div class="empty" style="padding:10px">(沒有子資料夾,可直接選這個)</div>`);
220
+ }
221
+ function fsHome(){ fsGo(window._fsHome||null); }
222
+ function closeFs(){ $("#fsmodal").style.display="none"; }
223
+ function chooseFs(){ if(!fsPath) return; const p=fsPath; if(!spaces.includes(p)) spaces.push(p); curSpace=p; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace); renderSpaces(); closeFs(); $("#current").innerHTML=""; loadHistory(); }
224
+
131
225
  // 極簡 markdown 渲染(零依賴、可離線;夠用於 agent 產的報告)
132
226
  function mdRender(src){
133
227
  const lines=String(src).replace(/\r/g,"").split("\n"); const out=[]; let inCode=false,buf=[],inList=false;
@@ -145,14 +239,33 @@ function mdRender(src){
145
239
  return out.join("");
146
240
  }
147
241
  const IMG=/\.(png|jpe?g|gif|webp|svg)$/i, MD=/\.(md|markdown)$/i, HTMLF=/\.html?$/i, JSONF=/\.json$/i;
148
- const fileUrl=(id,path,extra="")=>"/v1/tasks/"+id+"/file?path="+encodeURIComponent(path)+"&token="+encodeURIComponent(TOKEN)+extra;
149
- async function viewFile(id, encPath, name){
150
- const path=decodeURIComponent(encPath); const v=$("#fview"); v.style.display="block";
151
- const bar=`<div class="vbar">📄 ${esc(name)} · <a href="${fileUrl(id,path)}" target="_blank">開新分頁</a> · <a href="${fileUrl(id,path,'&download=1')}">下載</a></div>`;
152
- if(IMG.test(name)){ v.innerHTML=bar+`<img class="vimg" src="${fileUrl(id,path)}">`; return; }
153
- if(HTMLF.test(name)){ v.innerHTML=bar+`<iframe class="vframe" sandbox src="${fileUrl(id,path)}"></iframe>`; return; }
242
+ // 取檔 URL 產生器(任務成品走 /tasks/<id>,工作台走 /workspaces?ws=,後者 ws 可為本地絕對路徑)
243
+ const TOK="&token="+encodeURIComponent(TOKEN);
244
+ const taskFileUrl=id=>(path,extra="")=>"/v1/tasks/"+id+"/file?path="+encodeURIComponent(path)+TOK+extra;
245
+ const wsFileUrl=ws=>(path,extra="")=>"/v1/workspaces/file?ws="+encodeURIComponent(ws)+"&path="+encodeURIComponent(path)+TOK+extra;
246
+ // 彩色 diff(綠 +/紅 -):渲染 kernel 算好的 _diff
247
+ function diffHtml(d){
248
+ if(!d) return "";
249
+ if(d.tooBig) return `<div class="diff"><span class="dh">+${d.added} -${d.removed} 行(差異過大,省略)</span></div>`;
250
+ const ch=(d.lines||[]).filter(l=>l.t!==" ").slice(0,40);
251
+ if(!ch.length) return "";
252
+ return `<div class="diff"><div class="dh">+${d.added} -${d.removed}</div>${ch.map(l=>`<div class="dl ${l.t==="+"?"add":"del"}">${esc(l.t+" "+l.s)}</div>`).join("")}</div>`;
253
+ }
254
+ // 完整步驟卡(展開過程):人話動作 + 編輯的彩色 diff
255
+ function logHtml(p){
256
+ if(!p||!(p.log||[]).length) return `<div class="empty">(尚無步驟)</div>`;
257
+ return p.log.map(s=>`<div class="logstep ${s.isError?"err":""}">▸ ${esc(TOOL_ZH[s.name]||s.name)}${s.summary?` <span class="dim">${esc(s.summary)}</span>`:""}${s.diff?diffHtml(s.diff):""}</div>`).join("");
258
+ }
259
+ let expandedLog=false;
260
+ function toggleLog(){ expandedLog=!expandedLog; if(liveTask) renderCurrent(liveTask); }
261
+
262
+ async function renderFile(urlFn, encPath, name, sel){
263
+ const path=decodeURIComponent(encPath); const v=document.querySelector(sel); v.style.display="block";
264
+ const bar=`<div class="vbar">📄 ${esc(name)} · <a href="${urlFn(path)}" target="_blank">開新分頁</a> · <a href="${urlFn(path,'&download=1')}">下載</a></div>`;
265
+ if(IMG.test(name)){ v.innerHTML=bar+`<img class="vimg" src="${urlFn(path)}">`; return; }
266
+ if(HTMLF.test(name)){ v.innerHTML=bar+`<iframe class="vframe" sandbox src="${urlFn(path)}"></iframe>`; return; }
154
267
  v.innerHTML=bar+`<div class="empty">載入中…</div>`;
155
- const txt=await fetch(fileUrl(id,path)).then(r=>r.ok?r.text():null).catch(()=>null);
268
+ const txt=await fetch(urlFn(path)).then(r=>r.ok?r.text():null).catch(()=>null);
156
269
  if(txt==null){ v.innerHTML=bar+`<div class="empty">(無法以文字呈現,請下載)</div>`; return; }
157
270
  let body;
158
271
  if(MD.test(name)) body=`<div class="md">${mdRender(txt)}</div>`;
@@ -160,6 +273,34 @@ async function viewFile(id, encPath, name){
160
273
  else body=`<pre class="viewer-pre">${esc(txt)}</pre>`;
161
274
  v.innerHTML=bar+body;
162
275
  }
276
+ function viewFile(id, encPath, name){ return renderFile(taskFileUrl(id), encPath, name, "#fview"); }
277
+ function wbView(encPath, name){ const h=$("#wbhint"); if(h) h.style.display="none"; return renderFile(wsFileUrl(curSpace), encPath, name, "#wbview"); }
278
+
279
+ // 工作台分頁:逐層瀏覽 / 看 / 刪
280
+ function fmtSize(n){ return n<1024?n+" B":n<1048576?(n/1024).toFixed(1)+" KB":(n/1048576).toFixed(1)+" MB"; }
281
+ let wbSub="";
282
+ function wbNav(s){ wbSub=decodeURIComponent(s); loadWorkbench(); }
283
+ function wbUp(){ wbSub=wbSub.includes("/")?wbSub.slice(0,wbSub.lastIndexOf("/")):""; loadWorkbench(); }
284
+ async function loadWorkbench(){
285
+ $("#wbview").style.display="none"; { const h=$("#wbhint"); if(h) h.style.display=""; }
286
+ const r=await api("/v1/workspaces/files?ws="+encodeURIComponent(curSpace)+"&sub="+encodeURIComponent(wbSub)).then(r=>r.json()).catch(()=>({dirs:[],files:[]}));
287
+ const root=curSpace.startsWith("/")?curSpace:curSpace;
288
+ let html=`<div class="loc">📂 ${esc(root)}${wbSub?" / "+esc(wbSub):""}</div>`;
289
+ if(wbSub) html+=`<div class="wbrow up" onclick="wbUp()"><span class="wbname">⬆ 上一層</span></div>`;
290
+ html+=(r.dirs||[]).map(d=>`<div class="wbrow" onclick="wbNav('${encodeURIComponent((wbSub?wbSub+'/':'')+d)}')"><span class="wbname">📁 ${esc(d)}/</span></div>`).join("");
291
+ html+=(r.files||[]).map(f=>{const full=(wbSub?wbSub+'/':'')+f.name;return `<div class="wbrow"><span class="wbname" onclick="wbView('${encodeURIComponent(full)}','${esc(f.name)}')">📄 ${esc(f.name)}</span><span class="wbmeta">${fmtSize(f.size)}</span><span class="wbdel" title="刪除" onclick="wbDelete('${encodeURIComponent(full)}','${esc(f.name)}')">🗑</span></div>`;}).join("");
292
+ if(!(r.dirs||[]).length && !(r.files||[]).length) html+=`<div class="empty">(${wbSub?"這個資料夾是空的":"這個專案還沒有檔案,去「許願」交辦一件事吧"})</div>`;
293
+ $("#wbfiles").innerHTML=html;
294
+ }
295
+ async function wbDelete(encPath, name){ if(!confirm("刪除 "+name+"?此動作無法復原。")) return; await api("/v1/workspaces/file?ws="+encodeURIComponent(curSpace)+"&path="+encodeURIComponent(decodeURIComponent(encPath)),{method:"DELETE"}); loadWorkbench(); }
296
+ function switchTab(name){
297
+ const wish=name==="wish";
298
+ $("#wishview").style.display=wish?"":"none";
299
+ $("#workbench").style.display=wish?"none":"";
300
+ $("#tab-wish").classList.toggle("active",wish);
301
+ $("#tab-wb").classList.toggle("active",!wish);
302
+ if(!wish){ wbSub=""; loadWorkbench(); }
303
+ }
163
304
 
164
305
  let activeId = null, polling = null;
165
306
 
@@ -192,12 +333,15 @@ async function poll() {
192
333
 
193
334
  function renderCurrent(t) {
194
335
  const a = t.result?.artifacts, files = a ? [...(a.created||[]).map(f=>[f,"new"]), ...(a.modified||[]).map(f=>[f,"mod"])] : [];
336
+ const p = t.progress||{}, nLog=(p.log||[]).length;
337
+ const logSec = nLog ? `<div class="logtoggle" onclick="toggleLog()">${expandedLog?"▾ 收合過程":"▸ 展開過程("+nLog+" 步)"}</div>${expandedLog?`<div class="loglist">${logHtml(p)}</div>`:""}` : "";
195
338
  $("#current").innerHTML = `<div class="card">
196
339
  <div class="goal">${esc(t.goal||"任務")}<span class="wsbadge">📁 ${esc(t.workspace||"default")}</span></div>
197
340
  <span class="status ${statusClass(t.status)}">${statusText(t.status)}${t.rounds?` · ${t.rounds} 輪`:""}</span>
198
341
  ${CANCELLABLE.includes(t.status)?`<button class="cancel" onclick="cancelTask('${t.taskId}')">停止</button>`:""}
199
342
  ${t.status==="running"||t.status==="queued"?progressHtml(t):""}
200
343
  ${todosHtml(t.progress)}
344
+ ${logSec}
201
345
  ${t.status==="needs-input"?`<div class="qbox"><div class="q">❓ ${esc(t.pending?.question)}</div>
202
346
  <input id="ans" placeholder="輸入你的回答,按 Enter 送出"></div>`:""}
203
347
  ${t.status==="done"?`<div class="summary">${esc(t.result?.text||"")}</div>`:""}
@@ -205,6 +349,13 @@ function renderCurrent(t) {
205
349
  ${files.length?`<div class="files">📦 成品:${files.map(([f,k])=>`<span class="file ${k==="mod"?"mod":""}" onclick="viewFile('${t.taskId}','${encodeURIComponent(f)}','${esc(f)}')">${k==="mod"?"~":"+"} ${esc(f)}</span>`).join("")}</div>`:""}
206
350
  ${t.result&&t.result.workspaceDir?`<div class="loc">📂 檔案位置:<code title="點擊複製" onclick="navigator.clipboard&&navigator.clipboard.writeText('${esc(t.result.workspaceDir)}')">${esc(t.result.workspaceDir)}</code></div>`:""}
207
351
  <div class="viewer" id="fview" style="display:none"></div>
352
+ ${t.status==="done"?`<div class="followup">
353
+ <button class="ghost" onclick="toggleFollowup()">↳ 繼續/調整這個成果</button>
354
+ <div id="fubox" style="display:none;margin-top:8px">
355
+ <textarea id="fugoal" placeholder="想怎麼調整或繼續深入?(會接續這次的對話與檔案,agent 記得來龍去脈)"></textarea>
356
+ <button onclick="submitFollowup('${esc(t.sessionId||"")}','${esc(t.pack||"general")}','${esc(t.workspace||"default")}')">送出 →</button>
357
+ </div>
358
+ </div>`:""}
208
359
  </div>`;
209
360
  if (t.status==="needs-input") {
210
361
  const inp = $("#ans"); inp.focus();
@@ -216,11 +367,20 @@ function renderCurrent(t) {
216
367
  }
217
368
  }
218
369
 
370
+ // 繼續/調整:送出接續上一個任務對話(sessionId)+ 同工作區的後續任務
371
+ function toggleFollowup(){ const b=$("#fubox"); if(b){ b.style.display=b.style.display==="none"?"":"none"; if(b.style.display!=="none") $("#fugoal").focus(); } }
372
+ async function submitFollowup(sessionId, pack, workspace){
373
+ const goal=$("#fugoal").value.trim(); if(!goal) return;
374
+ const r=await api("/v1/tasks",{method:"POST",body:JSON.stringify({pack, mode:"goal", goal, workspace, sessionId})}).then(r=>r.json());
375
+ if(r.error){ alert(r.error); return; }
376
+ activeId=r.taskId; expandedLog=false; liveTask=null; poll(); window.scrollTo({top:0,behavior:"smooth"});
377
+ }
378
+
219
379
  async function loadHistory() {
220
380
  const r = await api("/v1/tasks").then(r=>r.json());
221
381
  const list = (r.tasks||[]).filter(t=>t.mode==="goal" && (t.workspace||"default")===curSpace).reverse();
222
382
  $("#history").innerHTML = list.length ? list.map(t=>`<div class="hist" onclick="openTask('${t.taskId}')">
223
- <div class="g">${esc(t.goal||t.taskId)} <span class="status ${statusClass(t.status)}">${statusText(t.status)}</span></div>
383
+ <div class="g">${t.continued?'<span class="cont" title="接續前一個任務">↳</span> ':''}${esc(t.goal||t.taskId)} <span class="status ${statusClass(t.status)}">${statusText(t.status)}</span></div>
224
384
  <div class="m">${esc(t.createdAt)}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
225
385
  }
226
386
  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"}); }
@@ -0,0 +1,30 @@
1
+ // 行級 diff(LCS)— 給 TUI/app 渲染彩色 diff。回傳 { lines:[{t,s}], added, removed } 或 { tooBig } 或 null(無變化)。
2
+ // kernel 在 wrapUndo 已抓到 before(undo 快照),改完讀 after,集中算 diff,所有 pack 的 edit/write 免改。
3
+ export function lineDiff(before, after, { maxLines = 600 } = {}) {
4
+ if (before === after) return null;
5
+ const a = before == null ? [] : String(before).replace(/\n$/, '').split('\n');
6
+ const b = after == null ? [] : String(after).replace(/\n$/, '').split('\n');
7
+ if (a.length === 1 && a[0] === '' && before == null) a.length = 0;
8
+ if (b.length === 1 && b[0] === '' && after == null) b.length = 0;
9
+ const m = a.length, n = b.length;
10
+ if (m > maxLines || n > maxLines) return { tooBig: true, added: n, removed: m };
11
+
12
+ // LCS DP(自後往前)
13
+ const dp = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
14
+ for (let i = m - 1; i >= 0; i--) for (let j = n - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
15
+
16
+ const lines = [];
17
+ let i = 0, j = 0;
18
+ while (i < m && j < n) {
19
+ if (a[i] === b[j]) { lines.push({ t: ' ', s: a[i] }); i++; j++; }
20
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { lines.push({ t: '-', s: a[i] }); i++; }
21
+ else { lines.push({ t: '+', s: b[j] }); j++; }
22
+ }
23
+ while (i < m) lines.push({ t: '-', s: a[i++] });
24
+ while (j < n) lines.push({ t: '+', s: b[j++] });
25
+
26
+ const added = lines.filter((l) => l.t === '+').length;
27
+ const removed = lines.filter((l) => l.t === '-').length;
28
+ if (!added && !removed) return null;
29
+ return { lines, added, removed };
30
+ }
@@ -11,6 +11,7 @@ import { createPermissionStep } from './security/permission-step.js';
11
11
  import { fileAllowStore, memoryAllowStore } from './security/allow-store.js';
12
12
  import { normalizeSandbox, wrapWithSeatbelt, sandboxViolation } from './security/sandbox.js';
13
13
  import { dangerousReason } from './security/danger.js';
14
+ import { lineDiff } from './diff.js';
14
15
  import { spawnSync } from 'node:child_process';
15
16
  import { createMemory } from './memory.js';
16
17
  import { createPlaybook } from './playbook.js';
@@ -94,20 +95,31 @@ function wrapSandboxable(tool, { cwd, getSandbox, getSandboxConfig }) {
94
95
 
95
96
  // undo 快照:mutating 且帶 args.path 的工具(檔案編輯類),執行前記錄檔案原狀,供 kernel.undo() 還原。
96
97
  // 「以 path 指涉被改檔案」是常見約定;非檔案型 mutating 工具(bash/sql_exec 無 path)不受影響。
98
+ const isTextContent = (s) => s == null || !String(s).includes("\u0000");
97
99
  function wrapUndo(tool, { cwd, undoStack }) {
98
100
  if (tool.mutating !== true || typeof tool.execute !== 'function') return tool;
99
101
  const orig = tool.execute.bind(tool);
100
102
  return {
101
103
  ...tool,
102
- execute: (id, params, ...rest) => {
104
+ execute: async (id, params, ...rest) => {
105
+ let abs = null, before = null;
103
106
  if (params?.path) {
104
- const p = isAbsolute(params.path) ? params.path : join(cwd, params.path);
107
+ abs = isAbsolute(params.path) ? params.path : join(cwd, params.path);
105
108
  try {
106
- undoStack.push({ path: p, rel: params.path, before: existsSync(p) ? readFileSync(p, 'utf8') : null });
109
+ before = existsSync(abs) ? readFileSync(abs, 'utf8') : null;
110
+ undoStack.push({ path: abs, rel: params.path, before });
107
111
  if (undoStack.length > 50) undoStack.shift();
108
112
  } catch { /* 略 */ }
109
113
  }
110
- return orig(id, params, ...rest);
114
+ const result = await orig(id, params, ...rest);
115
+ // 集中算 diff:用 before 快照 + 改後內容,掛在 result._diff(不進 LLM content,僅供 app 渲染)
116
+ if (abs && result && typeof result === 'object' && isTextContent(before)) {
117
+ try {
118
+ const after = existsSync(abs) ? readFileSync(abs, 'utf8') : null;
119
+ if (isTextContent(after)) { const d = lineDiff(before, after); if (d) result._diff = { path: params.path, ...d }; }
120
+ } catch { /* 略 */ }
121
+ }
122
+ return result;
111
123
  },
112
124
  };
113
125
  }