xitto-kernel 0.8.5 → 0.9.3

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.3
4
+
5
+ - **許願台佈局優化(視覺層次 + 互動細節)**:
6
+ - **頂部列**:專案控制(下拉 + 選資料夾 + 新專案)群組成一個卡片靠右、加底部分隔線、置中對齊;標題與控制不再擠成一團;窄螢幕隱藏副標
7
+ - **左欄**:歷史成品與檔案兩段做成卡片區塊;**當前任務在歷史列高亮**(知道你正在看哪個);時間改友善格式(月/日 時:分);列項改輕量(hover/active),不再雙層卡片
8
+ - **許願框**:focus 高亮邊框 + 「⌘/Ctrl+Enter 送出」提示與快捷鍵
9
+ - **主區**:歡迎/空狀態改虛線框、置中,更像「等你下訂單」
10
+ - 純前端(CSS/HTML/小 JS);測試 192/192 + JS 語法 + 結構驗證
11
+
12
+ ## 0.9.2
13
+
14
+ - **修:報告顯示完成但找不到真實檔案**(成品寫到工作區外)。從執行歷史查出:某任務 workspace=`/Users/…/Xiza`(本地就地),但 agent 把報告 `write` 到 `/tmp/…`、`/app/…`(絕對路徑,工作區外)→ 成品掃描只看工作區 → `artifacts:{created:[]}`,但 summary 說完成 → 使用者看到「有報告」卻找不到檔。兩道修法:
15
+ - **告知工作目錄**:system prompt 明確寫出 cwd +「請用相對路徑寫在此目錄內,不要寫到 /tmp、/app 等外面」(agent 原本不知道工作目錄在哪,只能亂猜)
16
+ - **寫檔限制在工作區內**:general/coding/fs-tools 的 `write`/`edit` confine 到 cwd,逃逸(絕對路徑外/`../`)直接擋下並回錯誤;讀檔不限制
17
+ - 4 個新測試(相對 OK/絕對逃逸擋/`..` 擋/`/app` 擋)+ 真實 model 端到端(report.md 落在工作區內、artifacts 正確)。測試 192/192
18
+
19
+ ## 0.9.1
20
+
21
+ - **修:任務一直迴圈無法結束**。從持久化的執行歷史分析出:某任務(查 2026 世界盃淘汰賽賽程——資訊不存在)35 步 6 輪、web_search×11 + web_fetch×23 不停繞圈,因為查不到、驗收一直判未達成、agent 每輪都有動作所以「無進展」偵測不到 → 跑到上限/被手動取消。三道防護:
22
+ - **目標迴圈**:驗收回饋連續重複(agent 在繞圈、沒朝驗收要求收斂)→ 連 2 次相同就停(stalled),不再因「有動作」而空轉到上限
23
+ - **agent loop 硬上限**:單回合工具呼叫達 `maxSteps`(80) → 注入「別再用工具,用現有資訊作結」逼它收尾(修 `while(true)` 無上限的潛在無限迴圈)
24
+ - **server**:goal 任務 `maxRounds` 由 12 降到 8(許願台 fire-and-forget,不宜跑太久)
25
+ - 1 個新測試(不可達成目標在 ≤4 輪內停止,非跑到上限)。測試 190/190
26
+
27
+ ## 0.9.0
28
+
29
+ - **許願台改單頁佈局(移除分頁,許願與工作台同頁呈現)**:原本「許願 | 工作台」要切分頁。改成一頁看全部:
30
+ - **頂部** = 許願輸入;**左欄** = 歷史成品 + 📂 檔案瀏覽器(各自獨立內捲,都到得了底);**主區** = 當前任務/進度/成品 + 檔案預覽(共用一個 #fview)
31
+ - 點任一檔(成品檔 或 工作區檔)都在**同一個主區預覽**;檔案瀏覽器逐層導航
32
+ - 任務完成自動刷新左欄檔案列;切換空間/新專案/選資料夾都同步刷新歷史+檔案
33
+ - 移除 tab 切換邏輯與多餘 DOM;交辦、看歷史、瀏覽檔案、預覽全在一頁,不再跳來跳去
34
+ - 純前端重構;測試 189/189 + 內嵌 JS 語法檢查 + 服務頁面結構驗證(.layout/.nav/.work/#fview、無 tabs、div 平衡)
35
+
36
+ ## 0.8.6
37
+
38
+ - **修:工作台預覽開啟時目錄捲不動**。`.wbleft`(檔案列) 是 `position:sticky`,清單比視窗高時 top 被釘在 14px、底部就到不了(sticky 陷阱,預覽讓整列變高時浮現)。改成側欄/檔案列/預覽**各自獨立內捲**(`max-height:calc(100vh - 28px) + overflow:auto`),不再靠頁面捲動;窄螢幕(<=860px)回到單欄、取消內捲。測試 189/189。
39
+
3
40
  ## 0.8.5
4
41
 
5
42
  - **許願台重啟後歷史還在(持久化)**:原本任務清單與對話 session 都是 in-memory,重啟全沒。改成落地:
package/README.md CHANGED
@@ -110,9 +110,7 @@ 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.8.5",
3
+ "version": "0.9.3",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
package/src/app/server.js CHANGED
@@ -272,7 +272,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
272
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); };
273
273
  if (spec.mode === 'goal') {
274
274
  // 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
275
- 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 }) });
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
276
  sess.history = o.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
277
277
  try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
278
278
  // 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
@@ -9,18 +9,30 @@
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; } }
19
- header { display:flex; align-items:baseline; gap:10px; margin-bottom:6px; }
20
- header h1 { font-size:22px; margin:0; }
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; background:var(--card); border:1px solid var(--line); border-radius:12px; padding:10px 12px; }
16
+ .navsec h3 { margin:0 0 8px; position:sticky; top:0; }
17
+ .hist-sec #history, .file-sec #wbfiles { overflow:auto; } /* 兩段各自內捲,都到得了底 */
18
+ .hist-sec { flex:0 1 auto; max-height:44vh; } .hist-sec #history { max-height:40vh; }
19
+ .file-sec { flex:1 1 auto; min-height:120px; } .file-sec #wbfiles { flex:1; }
20
+ .work { min-width:0; }
21
+ .welcome { padding:56px 24px; text-align:center; line-height:2; font-size:15px; border:1px dashed var(--line); border-radius:14px; }
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; } }
23
+ header { display:flex; align-items:center; gap:12px; padding-bottom:14px; border-bottom:1px solid var(--line); }
24
+ header h1 { font-size:20px; margin:0; white-space:nowrap; }
21
25
  header .sub { color:var(--dim); font-size:13px; }
22
- .ask { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px; margin:18px 0; }
23
- .ask textarea { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:10px; padding:12px; font:inherit; resize:vertical; min-height:64px; }
26
+ .topctl { display:flex; align-items:center; gap:8px; background:var(--card); border:1px solid var(--line); border-radius:10px; padding:5px 8px; }
27
+ .prjlabel { color:var(--dim); font-size:12px; }
28
+ .topctl select { padding:5px 8px; max-width:200px; }
29
+ .topctl button { padding:5px 10px; font-size:13px; font-weight:500; }
30
+ @media (max-width:640px){ header .sub { display:none; } }
31
+ .ask { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px; margin:18px 0; transition:border-color .15s; }
32
+ .ask:focus-within { border-color:var(--accent); }
33
+ .ask textarea { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:10px; padding:12px; font:inherit; resize:vertical; min-height:70px; outline:none; }
34
+ .ask textarea:focus { border-color:var(--accent); }
35
+ .askhint { color:var(--dim); font-size:12px; }
24
36
  .row { display:flex; gap:10px; align-items:center; margin-top:10px; }
25
37
  select, button { font:inherit; }
26
38
  select { background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:8px; }
@@ -68,15 +80,14 @@
68
80
  .qbox .q { color:var(--warn); margin-bottom:8px; }
69
81
  .qbox input { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px; font:inherit; }
70
82
  h3 { color:var(--dim); font-size:13px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; margin:28px 0 8px; }
71
- .hist { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:11px 14px; margin:8px 0; cursor:pointer; }
72
- .hist:hover { border-color:var(--accent); }
73
- .hist .g { font-size:14px; } .hist .m { color:var(--dim); font-size:12px; }
83
+ .hist { border:1px solid transparent; border-radius:8px; padding:8px 10px; margin:3px 0; cursor:pointer; }
84
+ .hist:hover { background:#0c0e12; }
85
+ .hist.active { background:#0c0e12; border-color:var(--accent); }
86
+ .hist .g { font-size:13.5px; line-height:1.4; } .hist .m { color:var(--dim); font-size:11px; margin-top:2px; }
74
87
  .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; }
88
+ .wbrow { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:8px; margin:1px 0; }
89
+ .wbrow:hover { background:#0c0e12; }
90
+ .wbname { flex:1; color:var(--accent); cursor:pointer; font-size:13.5px; }
80
91
  .wbmeta { color:var(--dim); font-size:12px; }
81
92
  .wbdel { cursor:pointer; opacity:.6; } .wbdel:hover { opacity:1; }
82
93
  .followup { margin-top:14px; padding-top:12px; border-top:1px solid var(--line); }
@@ -105,11 +116,14 @@
105
116
  <div class="wrap">
106
117
  <header>
107
118
  <h1>🪄 xitto 許願台</h1>
108
- <span class="sub">說出你想完成的事,交給它去做、做完給你成品</span>
119
+ <span class="sub">說出你想完成的事,做完給你成品</span>
109
120
  <span class="spacer"></span>
110
- <select id="space" title="專案/空間:不同專案的檔案與記憶各自獨立"></select>
111
- <button class="ghost" id="browsebtn" title="瀏覽並選一個真實資料夾(本地模式)" style="display:none">📁 選資料夾</button>
112
- <button class="ghost" id="newspace" title="新專案">+</button>
121
+ <div class="topctl">
122
+ <span class="prjlabel">專案</span>
123
+ <select id="space" title="專案/空間:不同專案的檔案與記憶各自獨立"></select>
124
+ <button class="ghost" id="browsebtn" title="瀏覽並選一個真實資料夾(本地模式)" style="display:none">📁 選</button>
125
+ <button class="ghost" id="newspace" title="新增一個專案/空間">+ 新專案</button>
126
+ </div>
113
127
  </header>
114
128
 
115
129
  <div id="fsmodal" class="modal" style="display:none">
@@ -126,38 +140,31 @@
126
140
  </div>
127
141
  </div>
128
142
 
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>
143
+ <div class="ask">
144
+ <textarea id="goal" placeholder="說出你想完成的事⋯例如:把這個資料夾的 .md 整理成目錄 index.md;或抓 example.com 標題寫進 title.txt"></textarea>
145
+ <div class="row">
146
+ <select id="pack" title="領域"></select>
147
+ <span class="spacer"></span>
148
+ <span class="askhint">⌘ / Ctrl + Enter 送出</span>
149
+ <button id="go">交辦 →</button>
145
150
  </div>
146
- <aside class="side">
147
- <h3>歷史成品</h3>
148
- <div id="history"><div class="empty">還沒有任何任務。<br>左邊交辦一件事試試。</div></div>
149
- </aside>
150
151
  </div>
151
152
 
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>
153
+ <div class="layout">
154
+ <aside class="nav">
155
+ <section class="navsec hist-sec">
156
+ <h3>歷史成品</h3>
157
+ <div id="history"><div class="empty">還沒有任何任務。上面交辦一件事試試。</div></div>
158
+ </section>
159
+ <section class="navsec file-sec">
160
+ <h3>📂 檔案</h3>
161
+ <div id="wbfiles"></div>
162
+ </section>
163
+ </aside>
164
+ <main class="work">
165
+ <div id="current"><div class="empty welcome">👋 上面說出你想完成的事,交給它去做。<br>左邊看歷史成品與工作區檔案,點任一個在這裡展開。</div></div>
166
+ <div class="viewer" id="fview" style="display:none"></div>
167
+ </main>
161
168
  </div>
162
169
  </div>
163
170
 
@@ -194,7 +201,8 @@ let spaces = JSON.parse(localStorage.getItem("xk_spaces")||'["default"]');
194
201
  let curSpace = localStorage.getItem("xk_space")||"default";
195
202
  const spaceLabel=s=>s.startsWith("/")?("📁 "+(s.split("/").filter(Boolean).pop()||s)):s;
196
203
  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(); } };
204
+ function refreshAll(){ $("#fview").style.display="none"; wbSub=""; loadHistory(); loadFiles(); }
205
+ $("#space").onchange = () => { curSpace=$("#space").value; localStorage.setItem("xk_space",curSpace); $("#current").innerHTML=""; refreshAll(); };
198
206
  $("#newspace").onclick = () => {
199
207
  const raw=(prompt(LOCAL?"新專案:輸入名稱,或貼上一個真實資料夾的絕對路徑(本地模式會就地改該資料夾的檔,像 Claude Code)":"新專案名稱(英數/底線/連字號):")||"").trim();
200
208
  if(!raw) return;
@@ -202,7 +210,7 @@ $("#newspace").onclick = () => {
202
210
  if(!n) return;
203
211
  if(!spaces.includes(n)) spaces.push(n);
204
212
  curSpace=n; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace);
205
- renderSpaces(); $("#current").innerHTML=""; loadHistory();
213
+ renderSpaces(); $("#current").innerHTML=""; refreshAll();
206
214
  };
207
215
  renderSpaces();
208
216
 
@@ -220,7 +228,7 @@ async function fsGo(p){
220
228
  }
221
229
  function fsHome(){ fsGo(window._fsHome||null); }
222
230
  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(); }
231
+ 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
232
 
225
233
  // 極簡 markdown 渲染(零依賴、可離線;夠用於 agent 產的報告)
226
234
  function mdRender(src){
@@ -274,33 +282,23 @@ async function renderFile(urlFn, encPath, name, sel){
274
282
  v.innerHTML=bar+body;
275
283
  }
276
284
  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"); }
285
+ function wbView(encPath, name){ return renderFile(wsFileUrl(curSpace), encPath, name, "#fview"); }
278
286
 
279
- // 工作台分頁:逐層瀏覽 / / 刪
287
+ // 左欄檔案瀏覽器:逐層瀏覽 / 看(→ 主區 #fview) / 刪
280
288
  function fmtSize(n){ return n<1024?n+" B":n<1048576?(n/1024).toFixed(1)+" KB":(n/1048576).toFixed(1)+" MB"; }
281
289
  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=""; }
290
+ function wbNav(s){ wbSub=decodeURIComponent(s); loadFiles(); }
291
+ function wbUp(){ wbSub=wbSub.includes("/")?wbSub.slice(0,wbSub.lastIndexOf("/")):""; loadFiles(); }
292
+ async function loadFiles(){
286
293
  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>`;
294
+ let html=wbSub?`<div class="loc">📂 ${esc(wbSub)}</div>`:"";
289
295
  if(wbSub) html+=`<div class="wbrow up" onclick="wbUp()"><span class="wbname">⬆ 上一層</span></div>`;
290
296
  html+=(r.dirs||[]).map(d=>`<div class="wbrow" onclick="wbNav('${encodeURIComponent((wbSub?wbSub+'/':'')+d)}')"><span class="wbname">📁 ${esc(d)}/</span></div>`).join("");
291
297
  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>`;
298
+ if(!(r.dirs||[]).length && !(r.files||[]).length) html+=`<div class="empty">(${wbSub?"空資料夾":"還沒有檔案"})</div>`;
293
299
  $("#wbfiles").innerHTML=html;
294
300
  }
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
- }
301
+ 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
302
 
305
303
  let activeId = null, polling = null;
306
304
 
@@ -311,9 +309,11 @@ $("#go").onclick = async () => {
311
309
  $("#go").disabled = false;
312
310
  if (r.error) { alert(r.error); return; }
313
311
  $("#goal").value = "";
312
+ $("#fview").style.display="none"; expandedLog=false;
314
313
  activeId = r.taskId;
315
- poll();
314
+ poll(); loadHistory();
316
315
  };
316
+ $("#goal").addEventListener("keydown", e=>{ if((e.metaKey||e.ctrlKey) && e.key==="Enter"){ e.preventDefault(); $("#go").click(); } });
317
317
 
318
318
  function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled"||s==="interrupted")?"error":""; }
319
319
  function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷",interrupted:"已中斷(重啟)"})[s]||s; }
@@ -326,7 +326,7 @@ async function poll() {
326
326
  const t = await api("/v1/tasks/"+activeId).then(r=>r.json());
327
327
  liveTask = t;
328
328
  renderCurrent(t);
329
- if (t.status==="done" || t.status==="error") { loadHistory(); return; }
329
+ if (t.status==="done" || t.status==="error") { loadHistory(); loadFiles(); return; }
330
330
  if (t.status==="needs-input") return; // 等使用者回答
331
331
  polling = setTimeout(poll, 1200);
332
332
  }
@@ -348,7 +348,6 @@ function renderCurrent(t) {
348
348
  ${t.status==="error"?`<div class="summary">⚠ ${esc(t.error)}</div>`:""}
349
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>`:""}
350
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>`:""}
351
- <div class="viewer" id="fview" style="display:none"></div>
352
351
  ${t.status==="done"?`<div class="followup">
353
352
  <button class="ghost" onclick="toggleFollowup()">↳ 繼續/調整這個成果</button>
354
353
  <div id="fubox" style="display:none;margin-top:8px">
@@ -379,13 +378,14 @@ async function submitFollowup(sessionId, pack, workspace){
379
378
  async function loadHistory() {
380
379
  const r = await api("/v1/tasks").then(r=>r.json());
381
380
  const list = (r.tasks||[]).filter(t=>t.mode==="goal" && (t.workspace||"default")===curSpace).reverse();
382
- $("#history").innerHTML = list.length ? list.map(t=>`<div class="hist" onclick="openTask('${t.taskId}')">
381
+ $("#history").innerHTML = list.length ? list.map(t=>`<div class="hist${t.taskId===activeId?' active':''}" onclick="openTask('${t.taskId}')">
383
382
  <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>
384
- <div class="m">${esc(t.createdAt)}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
383
+ <div class="m">${esc(fmtTime(t.createdAt))}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
385
384
  }
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"}); }
385
+ function fmtTime(s){ try{ const d=new Date(s); return d.toLocaleString(undefined,{month:"numeric",day:"numeric",hour:"2-digit",minute:"2-digit"}); }catch{ return s; } }
386
+ async function openTask(id){ activeId=id; const t=await api("/v1/tasks/"+id).then(r=>r.json()); liveTask=t; $("#fview").style.display="none"; renderCurrent(t); loadHistory(); if(t.status==="running"||t.status==="queued"){poll();} window.scrollTo({top:0,behavior:"smooth"}); }
387
387
 
388
- loadHistory();
388
+ loadHistory(); loadFiles();
389
389
  </script>
390
390
  </body>
391
391
  </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;