xitto-kernel 0.8.4 → 0.9.2

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,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2
4
+
5
+ - **修:報告顯示完成但找不到真實檔案**(成品寫到工作區外)。從執行歷史查出:某任務 workspace=`/Users/…/Xiza`(本地就地),但 agent 把報告 `write` 到 `/tmp/…`、`/app/…`(絕對路徑,工作區外)→ 成品掃描只看工作區 → `artifacts:{created:[]}`,但 summary 說完成 → 使用者看到「有報告」卻找不到檔。兩道修法:
6
+ - **告知工作目錄**:system prompt 明確寫出 cwd +「請用相對路徑寫在此目錄內,不要寫到 /tmp、/app 等外面」(agent 原本不知道工作目錄在哪,只能亂猜)
7
+ - **寫檔限制在工作區內**:general/coding/fs-tools 的 `write`/`edit` confine 到 cwd,逃逸(絕對路徑外/`../`)直接擋下並回錯誤;讀檔不限制
8
+ - 4 個新測試(相對 OK/絕對逃逸擋/`..` 擋/`/app` 擋)+ 真實 model 端到端(report.md 落在工作區內、artifacts 正確)。測試 192/192
9
+
10
+ ## 0.9.1
11
+
12
+ - **修:任務一直迴圈無法結束**。從持久化的執行歷史分析出:某任務(查 2026 世界盃淘汰賽賽程——資訊不存在)35 步 6 輪、web_search×11 + web_fetch×23 不停繞圈,因為查不到、驗收一直判未達成、agent 每輪都有動作所以「無進展」偵測不到 → 跑到上限/被手動取消。三道防護:
13
+ - **目標迴圈**:驗收回饋連續重複(agent 在繞圈、沒朝驗收要求收斂)→ 連 2 次相同就停(stalled),不再因「有動作」而空轉到上限
14
+ - **agent loop 硬上限**:單回合工具呼叫達 `maxSteps`(80) → 注入「別再用工具,用現有資訊作結」逼它收尾(修 `while(true)` 無上限的潛在無限迴圈)
15
+ - **server**:goal 任務 `maxRounds` 由 12 降到 8(許願台 fire-and-forget,不宜跑太久)
16
+ - 1 個新測試(不可達成目標在 ≤4 輪內停止,非跑到上限)。測試 190/190
17
+
18
+ ## 0.9.0
19
+
20
+ - **許願台改單頁佈局(移除分頁,許願與工作台同頁呈現)**:原本「許願 | 工作台」要切分頁。改成一頁看全部:
21
+ - **頂部** = 許願輸入;**左欄** = 歷史成品 + 📂 檔案瀏覽器(各自獨立內捲,都到得了底);**主區** = 當前任務/進度/成品 + 檔案預覽(共用一個 #fview)
22
+ - 點任一檔(成品檔 或 工作區檔)都在**同一個主區預覽**;檔案瀏覽器逐層導航
23
+ - 任務完成自動刷新左欄檔案列;切換空間/新專案/選資料夾都同步刷新歷史+檔案
24
+ - 移除 tab 切換邏輯與多餘 DOM;交辦、看歷史、瀏覽檔案、預覽全在一頁,不再跳來跳去
25
+ - 純前端重構;測試 189/189 + 內嵌 JS 語法檢查 + 服務頁面結構驗證(.layout/.nav/.work/#fview、無 tabs、div 平衡)
26
+
27
+ ## 0.8.6
28
+
29
+ - **修:工作台預覽開啟時目錄捲不動**。`.wbleft`(檔案列) 是 `position:sticky`,清單比視窗高時 top 被釘在 14px、底部就到不了(sticky 陷阱,預覽讓整列變高時浮現)。改成側欄/檔案列/預覽**各自獨立內捲**(`max-height:calc(100vh - 28px) + overflow:auto`),不再靠頁面捲動;窄螢幕(<=860px)回到單欄、取消內捲。測試 189/189。
30
+
31
+ ## 0.8.5
32
+
33
+ - **許願台重啟後歷史還在(持久化)**:原本任務清單與對話 session 都是 in-memory,重啟全沒。改成落地:
34
+ - **任務清單** → `.xitto-server/tasks/<id>.json`(每任務一檔,狀態變更時覆寫),啟動載回 → **歷史成品重啟後自動顯示**
35
+ - **對話 session** → `.xitto-server/sessions/<id>.json`,啟動載回 → **重啟後仍能「繼續/調整」**(對話脈絡跨重啟)
36
+ - **重啟收尾**:載入時還停在 `running`/`queued`/`needs-input` 的(agent 已隨進程消失)標 `interrupted`「已中斷(重啟)」
37
+ - 對標 Claude Code「對話自動落地」;但許願台是**自動顯示歷史**(成品清單),非明確 `--resume`(它是 chat,單位不同)
38
+ - 1 個新測試(落地/載回/interrupted)+ 真實端到端:跑任務→重啟(新 server 同 baseDir)→歷史顯示 + 接續對話寫出「重啟前只在對話講過的偏好 42」。測試 189/189
39
+
3
40
  ## 0.8.4
4
41
 
5
42
  - **桌面雙欄佈局(善用寬螢幕)**:原本單條 760px 窄欄、左右大量留白。改用 CSS grid 雙欄:
package/README.md CHANGED
@@ -110,14 +110,14 @@ npm run serve:local # = LOCAL=1 SANDBOX=off,token 預設
110
110
  - **繼續/調整(迭代有脈絡)**:成品上有「↳ 繼續/調整這個成果」——打一句想改什麼/想深入什麼,送出一個**後續任務**,**接續這次的對話(sessionId)+ 同工作區**。agent 同時有「檔案 + 當時的討論與理由」,不只是檔案。預設每個許願是乾淨新對話(不暴脹),按「繼續」才接續那條線(像 ChatGPT 開新對話 vs 接著聊)。歷史以 `↳` 標出接續鏈
111
111
  - **歷史成品**:過往交辦的清單(願望 + 狀態),不是聊天串
112
112
 
113
- **桌面雙欄佈局**:許願頁=主區(許願+當前任務/成品)+ 歷史 sticky 側欄;工作台=檔案瀏覽器左、預覽右並排。容器 1180px,窄螢幕自動收單欄。
114
-
115
- **工作台分頁(看見並管理你的工作區)**:許願台頂部有「許願 | 📂 工作台」分頁(同頁切換,不是另開頁面)。**許願**=交辦任務的主流程;**工作台**=**逐層瀏覽**當前專案的檔案(像檔案總管:只列當前目錄的子資料夾+檔案,點資料夾才進去,不一次遞迴攤平),看/下載/刪。單一任務用許願就夠;做專案的人切到工作台看「我累積了什麼、拿任意檔、清理」——讓持久工作空間從隱形變成可見可管理。刻意保持輕量(檔案清單,不是 IDE)。
113
+ **單頁佈局(無分頁,一眼看全部)**:頂部**許願輸入** + 左欄(**歷史成品** + **📂 檔案瀏覽器**,各自內捲)+ 主區(**當前任務/進度/成品/檔案預覽**共用)。不用切分頁——交辦任務、看歷史、瀏覽工作區檔案、預覽內容都在同一頁。檔案瀏覽器**逐層導航**(像檔案總管,不一次遞迴攤平),點任一檔(成品或工作區)都在主區預覽。容器 1180px,窄螢幕(≤860px)自動收成單欄。刻意保持輕量(不是 IDE)。
116
114
 
117
115
  **持久工作空間(成品間的關係)**:每個成品是**獨立的對話**(不續接前一個,避免 context 暴脹),但**共用一個持久工作空間**(`.xitto-server/ws/<workspace>`,預設 `default`)——所以 ① **檔案留存**,後面的任務能接續前面的成果(「把我上次做的 plan.md 翻成英文」);② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——它**越用越懂你**,不再是每次都從零開始的陌生人。`workspace` 可在 POST 時指定(多使用者各自一個);網頁有「專案」下拉切換,每份成品卡標出 `📁 所屬空間`。
118
116
 
119
117
  **本地就地模式(像 Claude Code 改你選的真實資料夾)**:`XITTO_SERVER_LOCAL=1` 時,網頁多一個「**📁 選資料夾**」鈕——**用點的**從家目錄瀏覽進你的真實資料夾並選定(不用打路徑;瀏覽器拿不到絕對路徑,所以由 local server 端列資料夾),或「新專案」直接貼絕對路徑也行。任務就**就地改那個資料夾的檔**(不另開隔離副本),工作台列的也是它。這把「許願台(隔離,服務非技術使用者)」和「Claude Code(就地,改你現有的 codebase)」兩個模型打通:**本機自用想就地 → 給路徑;隔離/託管 → 給名稱**。**安全**:只在 `local` 模式才認絕對路徑;**託管模式收到絕對路徑會被消毒成管理空間,不會逃逸到主機任意路徑**。
120
118
 
119
+ **重啟後歷史還在(持久化)**:任務清單落地 `.xitto-server/tasks/`、對話 session 落地 `.xitto-server/sessions/`,啟動時載回——所以**重啟後歷史成品自動顯示、舊成品仍能「繼續/調整」**(對話脈絡也在)。重啟時還在跑/待答的任務會標「已中斷(重啟)」。對標 Claude Code「對話自動落地」,但許願台是**自動顯示歷史**(成品清單),而非 Claude Code 的明確 `--resume`。
120
+
121
121
  **溯源/檔案位置**:成品記錄它的**邏輯位置(workspace)**;**實體絕對路徑**預設不外露(託管不洩漏伺服器路徑),只在**本地模式**(`XITTO_SERVER_LOCAL=1`)才在成品附「📂 檔案位置」供你到 Finder/Explorer 找檔。
122
122
 
123
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.8.4",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
package/src/app/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // JSON 或 SSE 串流,以及「背景任務 + 完成通知(webhook)」—— 派任務出去、做完回呼,不用一直盯著。
4
4
  // 這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
5
5
  import { createServer } from 'node:http';
6
- import { mkdirSync, readFileSync, existsSync, rmSync, readdirSync, statSync } from 'node:fs';
6
+ import { mkdirSync, readFileSync, writeFileSync, 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
9
  import { homedir } from 'node:os';
@@ -100,12 +100,26 @@ export const mapEvent = (ev) => {
100
100
  * @param {(task:object)=>void} [o.onFinish] 每個任務 settle 後呼叫(拿來發 webhook)
101
101
  * @param {number} [o.maxEvents] 每任務保留最近幾筆事件(預設 500)
102
102
  */
103
- export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents = 500 } = {}) {
103
+ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents = 500, persistDir } = {}) {
104
104
  const tasks = new Map(); // id -> task
105
105
  const queue = []; // 等待中的 task
106
106
  const subs = new Map(); // id -> Set<(ev)=>void>
107
107
  let active = 0;
108
108
 
109
+ // 持久化:每個任務落地一個 json(重啟後歷史成品還在)。runtime 欄位(_agent/events…)不存。
110
+ const snapshot = (t) => ({ id: t.id, status: t.status, spec: t.spec, result: t.result, error: t.error, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, progress: t.progress || null, pending: t.pending || null });
111
+ const persistTask = (t) => { if (!persistDir) return; try { mkdirSync(persistDir, { recursive: true }); writeFileSync(join(persistDir, t.id + '.json'), JSON.stringify(snapshot(t))); } catch { /* 略 */ } };
112
+ if (persistDir && existsSync(persistDir)) {
113
+ for (const f of readdirSync(persistDir).filter((x) => x.endsWith('.json')).sort()) {
114
+ try {
115
+ const t = JSON.parse(readFileSync(join(persistDir, f), 'utf8'));
116
+ if (['running', 'queued', 'needs-input'].includes(t.status)) { t.status = 'interrupted'; t.pending = null; } // 進程已死 → 標中斷
117
+ t.events = [];
118
+ tasks.set(t.id, t);
119
+ } catch { /* 壞檔略 */ }
120
+ }
121
+ }
122
+
109
123
  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 });
110
124
 
111
125
  const emit = (t, ev) => {
@@ -135,6 +149,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
135
149
  const makeAsk = (t) => ({ question, options }) => {
136
150
  t.status = 'needs-input'; t.pending = { question: String(question || ''), options: options || null };
137
151
  emit(t, { type: 'needs_input', question: t.pending.question, options: t.pending.options });
152
+ persistTask(t);
138
153
  return new Promise((resolve) => { t._answer = resolve; });
139
154
  };
140
155
 
@@ -152,6 +167,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
152
167
  if (t._cancelling && t.status !== 'error') t.status = 'cancelled'; // 使用者中斷
153
168
  t.finishedAt = new Date().toISOString();
154
169
  emit(t, { type: 'end', status: t.status, result: t.result, error: t.error });
170
+ persistTask(t);
155
171
  active--;
156
172
  try { onFinish?.(t); } catch { /* webhook 錯不影響佇列 */ }
157
173
  pump();
@@ -163,6 +179,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
163
179
  enqueue(spec) {
164
180
  const t = { id: newId('t'), status: 'queued', spec: spec || {}, events: [], result: null, error: null, createdAt: new Date().toISOString(), startedAt: null, finishedAt: null };
165
181
  tasks.set(t.id, t);
182
+ persistTask(t);
166
183
  queue.push(t);
167
184
  pump();
168
185
  return t;
@@ -180,7 +197,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
180
197
  if (t.status === 'queued') {
181
198
  const i = queue.indexOf(t); if (i >= 0) queue.splice(i, 1);
182
199
  t.status = 'cancelled'; t.finishedAt = new Date().toISOString();
183
- emit(t, { type: 'end', status: 'cancelled' });
200
+ emit(t, { type: 'end', status: 'cancelled' }); persistTask(t);
184
201
  return true;
185
202
  }
186
203
  if (typeof t._answer === 'function') { const r = t._answer; t._answer = null; t.pending = null; r(''); } // 解除待答阻塞
@@ -193,7 +210,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
193
210
  const t = tasks.get(id);
194
211
  if (!t || typeof t._answer !== 'function') return false;
195
212
  const resolve = t._answer; t._answer = null; t.pending = null; t.status = 'running';
196
- emit(t, { type: 'answered', answer: String(text ?? '') });
213
+ emit(t, { type: 'answered', answer: String(text ?? '') }); persistTask(t);
197
214
  resolve(String(text ?? ''));
198
215
  return true;
199
216
  },
@@ -212,9 +229,14 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
212
229
  * @returns {import('node:http').Server}
213
230
  */
214
231
  export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2, local = false } = {}) {
215
- const sessions = new Map(); // sessionId -> { pack, history }
232
+ const sessions = new Map(); // sessionId -> { history }
216
233
  mkdirSync(baseDir, { recursive: true });
217
234
 
235
+ // 對話 session 持久化(讓「繼續/調整」跨重啟可用):啟動載回 + 每次更新落地。
236
+ const sessDir = join(baseDir, 'sessions');
237
+ try { if (existsSync(sessDir)) for (const f of readdirSync(sessDir).filter((x) => x.endsWith('.json'))) { try { sessions.set(f.replace(/\.json$/, ''), JSON.parse(readFileSync(join(sessDir, f), 'utf8'))); } catch { /* 略 */ } } } catch { /* 略 */ }
238
+ const persistSession = (id, sess) => { try { mkdirSync(sessDir, { recursive: true }); writeFileSync(join(sessDir, id + '.json'), JSON.stringify({ history: sess.history })); } catch { /* 略 */ } };
239
+
218
240
  const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); };
219
241
  // header bearer 為主;img/iframe/下載這類瀏覽器發起的 GET 無法帶 header,允許 ?token=(同源、PoC)
220
242
  const authed = (req) => { if (!token) return true; if (req.headers.authorization === `Bearer ${token}`) return true; try { return new URL(req.url, 'http://x').searchParams.get('token') === token; } catch { return false; } };
@@ -250,14 +272,14 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
250
272
  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); };
251
273
  if (spec.mode === 'goal') {
252
274
  // 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
253
- 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 }) });
254
- sess.history = o.history || []; sessions.set(sessionId, sess);
275
+ const o = await kernel.runOutcome(spec.goal || spec.input || "", { maxRounds: 8, history: sess.history, onEvent: wrapped, onAgent, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
276
+ sess.history = o.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
255
277
  try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
256
278
  // 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
257
279
  return { sessionId, workspace, workspaceDir: local ? resolve(workdir) : undefined, text: o.summary || lastText(sess.history), usage, rounds: o.rounds, done: o.done, aborted: o.aborted, artifacts: o.artifacts };
258
280
  }
259
281
  const r = await kernel.runTurn(spec.input || '', { history: sess.history, onEvent: wrapped, onAgent });
260
- sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
282
+ sess.history = r.messages || r.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
261
283
  return { sessionId, workspace, workspaceDir: local ? resolve(workdir) : undefined, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
262
284
  }
263
285
 
@@ -272,6 +294,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
272
294
 
273
295
  const tasks = createTaskStore({
274
296
  concurrency,
297
+ persistDir: join(baseDir, 'tasks'),
275
298
  runJob: (spec, emit, ask, onAgent) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }, ask, onAgent),
276
299
  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); },
277
300
  });
@@ -9,13 +9,17 @@
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
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
+ /* 單頁佈局:頂部許願 + 左欄(歷史+檔案) + 主區(當前任務/成品/預覽共用)。窄螢幕收單欄 */
13
+ .layout { display:grid; grid-template-columns:300px minmax(0,1fr); gap:24px; align-items:start; margin-top:18px; }
14
+ .nav { position:sticky; top:14px; max-height:calc(100vh - 28px); display:flex; flex-direction:column; gap:16px; }
15
+ .navsec { display:flex; flex-direction:column; min-height:0; }
16
+ .navsec h3 { margin:0 0 8px; }
17
+ .hist-sec #history, .file-sec #wbfiles { overflow:auto; } /* 兩段各自內捲,都到得了底 */
18
+ .hist-sec { flex:0 1 auto; max-height:42vh; } .hist-sec #history { max-height:38vh; }
19
+ .file-sec { flex:1 1 auto; min-height:0; } .file-sec #wbfiles { flex:1; }
20
+ .work { min-width:0; }
21
+ .welcome { padding:40px 16px; text-align:center; line-height:2; }
22
+ @media (max-width:860px){ .layout{ grid-template-columns:1fr; } .nav{ position:static; max-height:none; } .hist-sec,.hist-sec #history,.file-sec #wbfiles{ max-height:none; } }
19
23
  header { display:flex; align-items:baseline; gap:10px; margin-bottom:6px; }
20
24
  header h1 { font-size:22px; margin:0; }
21
25
  header .sub { color:var(--dim); font-size:13px; }
@@ -72,9 +76,6 @@
72
76
  .hist:hover { border-color:var(--accent); }
73
77
  .hist .g { font-size:14px; } .hist .m { color:var(--dim); font-size:12px; }
74
78
  .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
79
  .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
80
  .wbname { flex:1; color:var(--accent); cursor:pointer; font-size:14px; }
80
81
  .wbmeta { color:var(--dim); font-size:12px; }
@@ -126,38 +127,30 @@
126
127
  </div>
127
128
  </div>
128
129
 
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>
133
-
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>
130
+ <div class="ask">
131
+ <textarea id="goal" placeholder="說出你想完成的事⋯例如:把這個資料夾的 .md 整理成目錄 index.md;或抓 example.com 標題寫進 title.txt"></textarea>
132
+ <div class="row">
133
+ <select id="pack" title="領域"></select>
134
+ <span class="spacer"></span>
135
+ <button id="go">交辦 →</button>
145
136
  </div>
146
- <aside class="side">
147
- <h3>歷史成品</h3>
148
- <div id="history"><div class="empty">還沒有任何任務。<br>左邊交辦一件事試試。</div></div>
149
- </aside>
150
137
  </div>
151
138
 
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>
139
+ <div class="layout">
140
+ <aside class="nav">
141
+ <section class="navsec hist-sec">
142
+ <h3>歷史成品</h3>
143
+ <div id="history"><div class="empty">還沒有任何任務。上面交辦一件事試試。</div></div>
144
+ </section>
145
+ <section class="navsec file-sec">
146
+ <h3>📂 檔案</h3>
147
+ <div id="wbfiles"></div>
148
+ </section>
149
+ </aside>
150
+ <main class="work">
151
+ <div id="current"><div class="empty welcome">👋 上面說出你想完成的事,交給它去做。<br>左邊看歷史成品與工作區檔案,點任一個在這裡展開。</div></div>
152
+ <div class="viewer" id="fview" style="display:none"></div>
153
+ </main>
161
154
  </div>
162
155
  </div>
163
156
 
@@ -194,7 +187,8 @@ let spaces = JSON.parse(localStorage.getItem("xk_spaces")||'["default"]');
194
187
  let curSpace = localStorage.getItem("xk_space")||"default";
195
188
  const spaceLabel=s=>s.startsWith("/")?("📁 "+(s.split("/").filter(Boolean).pop()||s)):s;
196
189
  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(); } };
190
+ function refreshAll(){ $("#fview").style.display="none"; wbSub=""; loadHistory(); loadFiles(); }
191
+ $("#space").onchange = () => { curSpace=$("#space").value; localStorage.setItem("xk_space",curSpace); $("#current").innerHTML=""; refreshAll(); };
198
192
  $("#newspace").onclick = () => {
199
193
  const raw=(prompt(LOCAL?"新專案:輸入名稱,或貼上一個真實資料夾的絕對路徑(本地模式會就地改該資料夾的檔,像 Claude Code)":"新專案名稱(英數/底線/連字號):")||"").trim();
200
194
  if(!raw) return;
@@ -202,7 +196,7 @@ $("#newspace").onclick = () => {
202
196
  if(!n) return;
203
197
  if(!spaces.includes(n)) spaces.push(n);
204
198
  curSpace=n; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace);
205
- renderSpaces(); $("#current").innerHTML=""; loadHistory();
199
+ renderSpaces(); $("#current").innerHTML=""; refreshAll();
206
200
  };
207
201
  renderSpaces();
208
202
 
@@ -220,7 +214,7 @@ async function fsGo(p){
220
214
  }
221
215
  function fsHome(){ fsGo(window._fsHome||null); }
222
216
  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(); }
217
+ 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=""; refreshAll(); }
224
218
 
225
219
  // 極簡 markdown 渲染(零依賴、可離線;夠用於 agent 產的報告)
226
220
  function mdRender(src){
@@ -274,33 +268,23 @@ async function renderFile(urlFn, encPath, name, sel){
274
268
  v.innerHTML=bar+body;
275
269
  }
276
270
  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"); }
271
+ function wbView(encPath, name){ return renderFile(wsFileUrl(curSpace), encPath, name, "#fview"); }
278
272
 
279
- // 工作台分頁:逐層瀏覽 / / 刪
273
+ // 左欄檔案瀏覽器:逐層瀏覽 / 看(→ 主區 #fview) / 刪
280
274
  function fmtSize(n){ return n<1024?n+" B":n<1048576?(n/1024).toFixed(1)+" KB":(n/1048576).toFixed(1)+" MB"; }
281
275
  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=""; }
276
+ function wbNav(s){ wbSub=decodeURIComponent(s); loadFiles(); }
277
+ function wbUp(){ wbSub=wbSub.includes("/")?wbSub.slice(0,wbSub.lastIndexOf("/")):""; loadFiles(); }
278
+ async function loadFiles(){
286
279
  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>`;
280
+ let html=wbSub?`<div class="loc">📂 ${esc(wbSub)}</div>`:"";
289
281
  if(wbSub) html+=`<div class="wbrow up" onclick="wbUp()"><span class="wbname">⬆ 上一層</span></div>`;
290
282
  html+=(r.dirs||[]).map(d=>`<div class="wbrow" onclick="wbNav('${encodeURIComponent((wbSub?wbSub+'/':'')+d)}')"><span class="wbname">📁 ${esc(d)}/</span></div>`).join("");
291
283
  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>`;
284
+ if(!(r.dirs||[]).length && !(r.files||[]).length) html+=`<div class="empty">(${wbSub?"空資料夾":"還沒有檔案"})</div>`;
293
285
  $("#wbfiles").innerHTML=html;
294
286
  }
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
- }
287
+ async function wbDelete(encPath, name){ if(!confirm("刪除 "+name+"?此動作無法復原。")) return; await api("/v1/workspaces/file?ws="+encodeURIComponent(curSpace)+"&path="+encodeURIComponent(decodeURIComponent(encPath)),{method:"DELETE"}); loadFiles(); }
304
288
 
305
289
  let activeId = null, polling = null;
306
290
 
@@ -311,12 +295,13 @@ $("#go").onclick = async () => {
311
295
  $("#go").disabled = false;
312
296
  if (r.error) { alert(r.error); return; }
313
297
  $("#goal").value = "";
298
+ $("#fview").style.display="none"; expandedLog=false;
314
299
  activeId = r.taskId;
315
300
  poll();
316
301
  };
317
302
 
318
- function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled")?"error":""; }
319
- function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷"})[s]||s; }
303
+ function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled"||s==="interrupted")?"error":""; }
304
+ function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷",interrupted:"已中斷(重啟)"})[s]||s; }
320
305
  const CANCELLABLE = ["queued","running","needs-input"];
321
306
  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>`; }
322
307
  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;} } }
@@ -326,7 +311,7 @@ async function poll() {
326
311
  const t = await api("/v1/tasks/"+activeId).then(r=>r.json());
327
312
  liveTask = t;
328
313
  renderCurrent(t);
329
- if (t.status==="done" || t.status==="error") { loadHistory(); return; }
314
+ if (t.status==="done" || t.status==="error") { loadHistory(); loadFiles(); return; }
330
315
  if (t.status==="needs-input") return; // 等使用者回答
331
316
  polling = setTimeout(poll, 1200);
332
317
  }
@@ -348,7 +333,6 @@ function renderCurrent(t) {
348
333
  ${t.status==="error"?`<div class="summary">⚠ ${esc(t.error)}</div>`:""}
349
334
  ${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>`:""}
350
335
  ${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>`:""}
351
- <div class="viewer" id="fview" style="display:none"></div>
352
336
  ${t.status==="done"?`<div class="followup">
353
337
  <button class="ghost" onclick="toggleFollowup()">↳ 繼續/調整這個成果</button>
354
338
  <div id="fubox" style="display:none;margin-top:8px">
@@ -385,7 +369,7 @@ async function loadHistory() {
385
369
  }
386
370
  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"}); }
387
371
 
388
- loadHistory();
372
+ loadHistory(); loadFiles();
389
373
  </script>
390
374
  </body>
391
375
  </html>
@@ -50,6 +50,7 @@ export class Agent {
50
50
  };
51
51
  this.listeners = new Set();
52
52
  this.streamFn = options.streamFn; // 必傳(xitto 都注入;缺則無法串流)
53
+ this.maxSteps = options.maxSteps || 80; // 單回合硬上限:防 agent 無限呼叫工具繞圈
53
54
  this.getApiKey = options.getApiKey;
54
55
  this.beforeToolCall = options.beforeToolCall;
55
56
  this.afterToolCall = options.afterToolCall;
@@ -135,6 +136,7 @@ export class Agent {
135
136
  const newMessages = [];
136
137
  let pending = initialMessages.slice();
137
138
  let firstTurn = true;
139
+ let steps = 0;
138
140
  while (true) {
139
141
  let hasMoreToolCalls = true;
140
142
  while (hasMoreToolCalls || pending.length) {
@@ -149,6 +151,10 @@ export class Agent {
149
151
  // abort 守衛:迴圈邊界若已中止,立即走 handleRunFailure(aborted)。
150
152
  // 真實 provider 由 fetch signal 中止串流;此守衛確保 fake/工具觸發的 abort 也確定性收尾。
151
153
  if (signal.aborted) throw new Error('Aborted');
154
+ // 硬上限:單回合工具呼叫太多 → 注入一句「別再用工具,直接用現有資訊作結」,逼它收尾
155
+ if (steps === this.maxSteps - 1) this._state.messages.push({ role: 'user', content: [{ type: 'text', text: `[系統] 已達工具呼叫上限(${this.maxSteps} 步)。請停止使用任何工具,直接根據目前已知資訊給出結論或說明卡在哪、缺什麼,然後結束。` }] });
156
+ if (steps >= this.maxSteps) { await this.emit({ type: 'agent_end', messages: newMessages }); return; }
157
+ steps++;
152
158
  const message = await this.streamAssistant(signal);
153
159
  newMessages.push(message);
154
160
  if (message.stopReason === 'error' || message.stopReason === 'aborted') {
@@ -236,6 +236,7 @@ export function createKernel(pack, config = {}) {
236
236
  pack.systemPrompt +
237
237
  loadContextFiles(cwd, pack.contextFiles) + // 注入領域規範檔(CLAUDE.md 等)
238
238
  '\n\n# 記憶與專案手冊\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) + '\n' + DEFAULT_PLAYBOOK_GUIDE + '\n' + DEFAULT_EPISODE_GUIDE +
239
+ '\n\n# 工作目錄\n你的工作目錄是:' + cwd + '\n所有檔案請用相對路徑寫在這個目錄內(如 report.md、data/x.csv)。除非使用者明確要求,絕對不要寫到此目錄之外(例如 /tmp、/app、/workspace、系統根目錄)——寫在外面使用者拿不到成品。' +
239
240
  '\n\n# 成品與暫存\n' + DEFAULT_OUTPUT_GUIDE +
240
241
  (memText ? `\n\n# 已記住的事實(跨 session)\n${memText}` : '') +
241
242
  (pbText ? `\n\n# 專案手冊(這個專案怎麼做事,跨 session 累積)\n${pbText}` : '') +
@@ -438,6 +439,7 @@ export function createKernel(pack, config = {}) {
438
439
  let lastRemaining = null;
439
440
  let noProgress = 0;
440
441
  let verifyErrors = 0;
442
+ let sameFeedback = 0;
441
443
  for (let round = 1; round <= maxRounds; round++) {
442
444
  opts.onRound?.({ round, maxRounds });
443
445
  const r = await api.runTurn(instruction, { history, onEvent: opts.onEvent, onAgent: opts.onAgent });
@@ -459,7 +461,9 @@ export function createKernel(pack, config = {}) {
459
461
  }
460
462
  verifyErrors = 0;
461
463
  const rem = normalizeFeedback(v.remaining);
462
- if (!r.turnModified && rem && rem === lastRemaining) return { done: false, stalled: true, rounds: round, history };
464
+ // 驗收回饋重複 = agent 在繞圈(即使一直有動作也沒朝驗收要求收斂,如查不到的資訊一直換來源)→ 2 次相同就停,別空轉到上限
465
+ if (rem && rem === lastRemaining) { if (++sameFeedback >= 2) return { done: false, stalled: true, rounds: round, history }; }
466
+ else sameFeedback = 0;
463
467
  lastRemaining = rem;
464
468
  instruction = `目標尚未達成。驗收回饋:${v.remaining}\n請繼續完成目標:${goal}`;
465
469
  }
@@ -30,6 +30,9 @@ const SYSTEM_PROMPT = [
30
30
  export function createCodingPack({ cwd = process.cwd() } = {}) {
31
31
  const readFiles = new Set(); // 已 read 過的絕對路徑(read 工具寫入、read-before-edit 守衛讀取)
32
32
  const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
33
+ // 寫檔限制在工作目錄內:逃逸 cwd(如 /tmp、/app)回 null。讀檔不限制。
34
+ const within = (p) => { const full = abs(p); const r = relative(cwd, full); return (r === '' || (!r.startsWith('..') && !isAbsolute(r))) ? full : null; };
35
+ const escapeErr = (path) => txt({ error: `只能寫在工作目錄內:${cwd}`, hint: '請用相對路徑(如 report.md)', path });
33
36
  const bg = createBackgroundTools(cwd); // bash_bg / bash_output / bash_kill
34
37
 
35
38
  const readTool = {
@@ -64,7 +67,7 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
64
67
  name: 'write', label: '寫檔', description: '建立或覆寫檔案', mutating: true,
65
68
  parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
66
69
  execute: async (_id, { path, content }) => {
67
- const p = abs(path);
70
+ const p = within(path); if (!p) return escapeErr(path);
68
71
  writeFileSync(p, content ?? '', 'utf8');
69
72
  readFiles.add(p); // 寫過即視為已知內容
70
73
  return txt({ written: path, bytes: Buffer.byteLength(content ?? '') });
@@ -76,7 +79,7 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
76
79
  description: '把檔案中的 oldText 換成 newText。oldText 必須唯一(出現多次會失敗,請加上下文;或設 replaceAll:true 全部取代)。',
77
80
  parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' }, replaceAll: { type: 'boolean' } }, required: ['path', 'oldText', 'newText'] },
78
81
  execute: async (_id, { path, oldText, newText, replaceAll }) => {
79
- const p = abs(path);
82
+ const p = within(path); if (!p) return escapeErr(path);
80
83
  if (!existsSync(p)) return txt({ error: '檔案不存在', path });
81
84
  const before = readFileSync(p, 'utf8');
82
85
  const occurrences = before.split(oldText).length - 1;
@@ -1,7 +1,7 @@
1
1
  // general pack — 通用自主 agent。廣的 system prompt + 檔案/shell/web 工具。
2
2
  // 搭配 kernel 的 runGoal(目標循環)+ 子 agent + MCP,即為「給目標、自己做到完成」的通用 agent。
3
3
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
4
- import { isAbsolute, join, extname } from 'node:path';
4
+ import { isAbsolute, join, extname, relative } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { createGrepTool, createGlobTool } from '../shared/code-nav.js';
7
7
  import { createWebFetchTool, createWebSearchTool, createHttpTool } from '../shared/web-tools.js';
@@ -24,6 +24,9 @@ const SYSTEM_PROMPT = [
24
24
  export function createGeneralPack({ cwd = process.cwd() } = {}) {
25
25
  const readFiles = new Set();
26
26
  const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
27
+ // 寫檔限制在工作目錄內:回解析後路徑,逃逸 cwd(如 /tmp、/app)回 null。讀檔不限制。
28
+ const within = (p) => { const full = abs(p); const r = relative(cwd, full); return (r === '' || (!r.startsWith('..') && !isAbsolute(r))) ? full : null; };
29
+ const escapeErr = (path) => txt({ error: `只能寫在工作目錄內:${cwd}`, hint: '請用相對路徑(如 report.md),不要寫到工作區之外', path });
27
30
 
28
31
  const read = {
29
32
  name: 'read', label: '讀檔', description: '讀取檔案內容', readOnly: true,
@@ -38,12 +41,12 @@ export function createGeneralPack({ cwd = process.cwd() } = {}) {
38
41
  const write = {
39
42
  name: 'write', label: '寫檔', description: '建立或覆寫檔案', mutating: true,
40
43
  parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
41
- execute: async (_id, { path, content }) => { const p = abs(path); writeFileSync(p, content ?? '', 'utf8'); readFiles.add(p); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
44
+ execute: async (_id, { path, content }) => { const p = within(path); if (!p) return escapeErr(path); writeFileSync(p, content ?? '', 'utf8'); readFiles.add(p); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
42
45
  };
43
46
  const edit = {
44
47
  name: 'edit', label: '編輯', description: '把檔案中的 oldText 換成 newText', mutating: true,
45
48
  parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'] },
46
- execute: async (_id, { path, oldText, newText }) => { const p = abs(path); if (!existsSync(p)) return txt({ error: '檔案不存在', path }); const b = readFileSync(p, 'utf8'); if (!b.includes(oldText)) return txt({ error: 'oldText 未找到', path }); writeFileSync(p, b.replace(oldText, newText), 'utf8'); return txt({ edited: path }); },
49
+ execute: async (_id, { path, oldText, newText }) => { const p = within(path); if (!p) return escapeErr(path); if (!existsSync(p)) return txt({ error: '檔案不存在', path }); const b = readFileSync(p, 'utf8'); if (!b.includes(oldText)) return txt({ error: 'oldText 未找到', path }); writeFileSync(p, b.replace(oldText, newText), 'utf8'); return txt({ edited: path }); },
47
50
  };
48
51
  const bash = {
49
52
  name: 'bash', label: 'bash', description: '執行 shell 命令(可選 timeout 秒數,預設 120)', mutating: true, sandboxable: true,
@@ -1,7 +1,7 @@
1
1
  // 共用檔案/shell 工具(read/ls/write/edit/bash)+ read-before-edit 守衛。
2
2
  // 多個 pack(coding/devops…)共用;read 附行號、edit 唯一性檢查、bash 用 spawnSync 捕捉 stderr。
3
3
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
4
- import { isAbsolute, join } from 'node:path';
4
+ import { isAbsolute, join, relative } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
 
7
7
  const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
@@ -13,6 +13,9 @@ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s
13
13
  export function createFsTools(cwd) {
14
14
  const readFiles = new Set();
15
15
  const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
16
+ // 寫檔限制在工作目錄內:逃逸 cwd(如 /tmp、/app)回 null。讀檔不限制。
17
+ const within = (p) => { const full = abs(p); const r = relative(cwd, full); return (r === '' || (!r.startsWith('..') && !isAbsolute(r))) ? full : null; };
18
+ const escapeErr = (path) => txt({ error: `只能寫在工作目錄內:${cwd}`, hint: '請用相對路徑(如 report.md)', path });
16
19
 
17
20
  const read = {
18
21
  name: 'read', label: '讀檔', readOnly: true,
@@ -38,14 +41,14 @@ export function createFsTools(cwd) {
38
41
  const write = {
39
42
  name: 'write', label: '寫檔', mutating: true, description: '建立或覆寫檔案',
40
43
  parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
41
- execute: async (_id, { path, content }) => { const p = abs(path); writeFileSync(p, content ?? '', 'utf8'); readFiles.add(p); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
44
+ execute: async (_id, { path, content }) => { const p = within(path); if (!p) return escapeErr(path); writeFileSync(p, content ?? '', 'utf8'); readFiles.add(p); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
42
45
  };
43
46
  const edit = {
44
47
  name: 'edit', label: '編輯', mutating: true,
45
48
  description: '把 oldText 換成 newText。oldText 須唯一(多次出現需 replaceAll)。',
46
49
  parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' }, replaceAll: { type: 'boolean' } }, required: ['path', 'oldText', 'newText'] },
47
50
  execute: async (_id, { path, oldText, newText, replaceAll }) => {
48
- const p = abs(path);
51
+ const p = within(path); if (!p) return escapeErr(path);
49
52
  if (!existsSync(p)) return txt({ error: '檔案不存在', path });
50
53
  const before = readFileSync(p, 'utf8');
51
54
  const n = before.split(oldText).length - 1;