xitto-kernel 0.9.2 → 0.9.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.4
4
+
5
+ - **執行中可中途補充(steering)**:任務跑到一半,使用者可隨時插話調整方向/補需求,不必取消重來。
6
+ - 排隊注入,**不中斷正在跑的工具**——下一個邊界才生效:
7
+ - agent 串流中 → 即時排進 agent 的 steeringQueue(turn 邊界 drain)
8
+ - 回合之間(goal loop 的驗收空檔,agent 已收尾)→ 緩衝到 task,kernel 下一輪用 `drainSteer` 折進指令
9
+ - 兩路互斥,不重複套用
10
+ - 後端:`createTaskStore.steer(id,text)` + `POST /v1/tasks/:id/steer`;kernel `runGoal` 新增 `opts.drainSteer` 鉤子
11
+ - 許願台:進行中顯示補充輸入框(Enter 送出)+「✋ 已收到,會在下一步納入」回饋;輸入內容/游標在每 1.2s 輪詢重繪間保留,不洗掉打到一半的字
12
+ - 測試 +4(196/196):串流即時路徑、回合間緩衝/drain-once、非進行中擋下、drainSteer 折進指令
13
+
14
+ ## 0.9.3
15
+
16
+ - **許願台佈局優化(視覺層次 + 互動細節)**:
17
+ - **頂部列**:專案控制(下拉 + 選資料夾 + 新專案)群組成一個卡片靠右、加底部分隔線、置中對齊;標題與控制不再擠成一團;窄螢幕隱藏副標
18
+ - **左欄**:歷史成品與檔案兩段做成卡片區塊;**當前任務在歷史列高亮**(知道你正在看哪個);時間改友善格式(月/日 時:分);列項改輕量(hover/active),不再雙層卡片
19
+ - **許願框**:focus 高亮邊框 + 「⌘/Ctrl+Enter 送出」提示與快捷鍵
20
+ - **主區**:歡迎/空狀態改虛線框、置中,更像「等你下訂單」
21
+ - 純前端(CSS/HTML/小 JS);測試 192/192 + JS 語法 + 結構驗證
22
+
3
23
  ## 0.9.2
4
24
 
5
25
  - **修:報告顯示完成但找不到真實檔案**(成品寫到工作區外)。從執行歷史查出:某任務 workspace=`/Users/…/Xiza`(本地就地),但 agent 把報告 `write` 到 `/tmp/…`、`/app/…`(絕對路徑,工作區外)→ 成品掃描只看工作區 → `artifacts:{created:[]}`,但 summary 說完成 → 使用者看到「有報告」卻找不到檔。兩道修法:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
package/src/app/server.js CHANGED
@@ -139,6 +139,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
139
139
  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); }
140
140
  else if (ev.type === 'round') { p.round = ev.round; if (ev.maxRounds) p.maxRounds = ev.maxRounds; p.thinking = ''; t._textbuf = ''; }
141
141
  else if (ev.type === 'phase') p.phase = ev.phase;
142
+ else if (ev.type === 'steered') { (p.steers ||= []).push(ev.text); if (p.steers.length > 8) p.steers.shift(); } // 使用者中途補充(給 UI 回饋「已收到」)
142
143
  else if (ev.type === 'needs_input') p.phase = 'needs-input';
143
144
  else if (ev.type === 'answered') p.phase = 'acting';
144
145
  else if (ev.type === 'end') p.phase = ev.status;
@@ -160,7 +161,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
160
161
  t.status = 'running'; t.startedAt = new Date().toISOString();
161
162
  emit(t, { type: 'status', status: 'running' });
162
163
  Promise.resolve()
163
- .then(() => runJob(t.spec, (ev) => emit(t, ev), makeAsk(t), (agent) => { t._agent = agent; }))
164
+ .then(() => runJob(t.spec, (ev) => emit(t, ev), makeAsk(t), (agent) => { t._agent = agent; }, () => { const b = t.steerBuf || []; t.steerBuf = []; return b; }))
164
165
  .then((result) => { t.status = 'done'; t.result = result; })
165
166
  .catch((e) => { t.status = 'error'; t.error = e.message || String(e); })
166
167
  .finally(() => {
@@ -214,6 +215,19 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
214
215
  resolve(String(text ?? ''));
215
216
  return true;
216
217
  },
218
+ // 中途補充(steering):任務進行中,使用者插話。agent 正在串流 → 即時排進 steeringQueue(下個 turn 邊界生效,不中斷當前工具);
219
+ // 回合之間(goal loop 的 checkGoal 空檔,agent 已收尾)→ 緩衝到 task,由 kernel 下一輪 drainSteer 折進指令。兩路互斥,不重複套用。
220
+ steer(id, text) {
221
+ const t = tasks.get(id);
222
+ if (!t || t.status !== 'running') return false;
223
+ const msg = String(text ?? '').trim();
224
+ if (!msg) return false;
225
+ const live = !!(t._agent && t._agent.state && t._agent.state.isStreaming);
226
+ if (live) { try { t._agent.steer({ role: 'user', content: [{ type: 'text', text: msg }] }); } catch { (t.steerBuf ||= []).push(msg); } }
227
+ else (t.steerBuf ||= []).push(msg);
228
+ emit(t, { type: 'steered', text: msg, queued: !live });
229
+ return true;
230
+ },
217
231
  stats: () => ({ active, queued: queue.length, total: tasks.size }),
218
232
  };
219
233
  }
@@ -257,7 +271,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
257
271
 
258
272
  // 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件;
259
273
  // ask(可選)= 澄清通道,讓 agent 在背景任務中暫停問使用者。
260
- async function runKernel(spec, onEvent, ask, onAgent) {
274
+ async function runKernel(spec, onEvent, ask, onAgent, drainSteer) {
261
275
  const make = PACKS[spec.pack || 'general'];
262
276
  if (!make) throw new Error(`未知 pack「${spec.pack}」,可用:${Object.keys(PACKS).join(', ')}`);
263
277
  // 持久工作空間(B 模型):workdir 綁 workspace(非 sessionId)→ 檔案留存 + 五層沉澱跨成品累積。
@@ -272,7 +286,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
272
286
  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
287
  if (spec.mode === 'goal') {
274
288
  // 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
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 }) });
289
+ const o = await kernel.runOutcome(spec.goal || spec.input || "", { maxRounds: 8, history: sess.history, onEvent: wrapped, onAgent, drainSteer, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
276
290
  sess.history = o.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
277
291
  try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
278
292
  // 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
@@ -295,7 +309,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
295
309
  const tasks = createTaskStore({
296
310
  concurrency,
297
311
  persistDir: join(baseDir, 'tasks'),
298
- runJob: (spec, emit, ask, onAgent) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }, ask, onAgent),
312
+ runJob: (spec, emit, ask, onAgent, drainSteer) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }, ask, onAgent, drainSteer),
299
313
  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); },
300
314
  });
301
315
 
@@ -359,6 +373,19 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
359
373
  return json(res, 200, { ok: true, taskId: mAns[1], status: 'running' });
360
374
  }
361
375
 
376
+ // 中途補充(steering):任務進行中,使用者插話調整方向/補需求。排隊注入,不中斷當前工作,下個邊界生效。
377
+ const mSteer = path.match(/^\/v1\/tasks\/([^/]+)\/steer$/);
378
+ if (req.method === 'POST' && mSteer) {
379
+ const body = await readBody(req);
380
+ const t = tasks.get(mSteer[1]);
381
+ if (!t) return json(res, 404, { error: 'task not found' });
382
+ if (t.status !== 'running') return json(res, 409, { error: '只有進行中的任務可以補充', status: t.status });
383
+ const ok = tasks.steer(mSteer[1], body.text);
384
+ if (!ok) return json(res, 400, { error: '補充內容為空或無法送出' });
385
+ log({ task: mSteer[1], action: 'steer' });
386
+ return json(res, 200, { ok: true, taskId: mSteer[1] });
387
+ }
388
+
362
389
  // 中斷任務(取消鈕):控制權在使用者手上,降低「啟動了控制不了的東西」的焦慮
363
390
  const mCancel = path.match(/^\/v1\/tasks\/([^/]+)\/cancel$/);
364
391
  if (req.method === 'POST' && mCancel) {
@@ -436,7 +463,7 @@ export function startServer() {
436
463
  console.log(`🪄 許願台:http://localhost:${port}/ (瀏覽器打開即用——說出目標、交付成品)`);
437
464
  console.log(`xitto-kernel server · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}${local ? ' · 本地模式(顯示檔案位置)' : ''}`);
438
465
  console.log(`token: ${token === 'dev-token' ? 'dev-token(請設 XITTO_SERVER_TOKEN)' : '(已設定)'}`);
439
- console.log('API:POST /v1/run · /v1/stream · /v1/tasks · /v1/tasks/:id/{answer,cancel}|GET /v1/tasks[/:id[/events|/file]] · /health');
466
+ console.log('API:POST /v1/run · /v1/stream · /v1/tasks · /v1/tasks/:id/{answer,steer,cancel}|GET /v1/tasks[/:id[/events|/file]] · /health');
440
467
  });
441
468
  return server;
442
469
  }
@@ -12,19 +12,27 @@
12
12
  /* 單頁佈局:頂部許願 + 左欄(歷史+檔案) + 主區(當前任務/成品/預覽共用)。窄螢幕收單欄 */
13
13
  .layout { display:grid; grid-template-columns:300px minmax(0,1fr); gap:24px; align-items:start; margin-top:18px; }
14
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; }
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
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; }
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
20
  .work { min-width:0; }
21
- .welcome { padding:40px 16px; text-align:center; line-height:2; }
21
+ .welcome { padding:56px 24px; text-align:center; line-height:2; font-size:15px; border:1px dashed var(--line); border-radius:14px; }
22
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:baseline; gap:10px; margin-bottom:6px; }
24
- header h1 { font-size:22px; margin:0; }
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; }
25
25
  header .sub { color:var(--dim); font-size:13px; }
26
- .ask { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px; margin:18px 0; }
27
- .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; }
28
36
  .row { display:flex; gap:10px; align-items:center; margin-top:10px; }
29
37
  select, button { font:inherit; }
30
38
  select { background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:8px; }
@@ -71,13 +79,19 @@
71
79
  .qbox { margin-top:12px; padding:12px; background:#1d1c12; border:1px solid var(--warn); border-radius:10px; }
72
80
  .qbox .q { color:var(--warn); margin-bottom:8px; }
73
81
  .qbox input { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px; font:inherit; }
82
+ .steerbox { margin-top:12px; padding-top:10px; border-top:1px dashed var(--line); }
83
+ .steernote { font-size:12.5px; color:var(--ok); margin-bottom:6px; }
84
+ #steerin { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px 11px; font:inherit; outline:none; }
85
+ #steerin:focus { border-color:var(--accent); }
74
86
  h3 { color:var(--dim); font-size:13px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; margin:28px 0 8px; }
75
- .hist { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:11px 14px; margin:8px 0; cursor:pointer; }
76
- .hist:hover { border-color:var(--accent); }
77
- .hist .g { font-size:14px; } .hist .m { color:var(--dim); font-size:12px; }
87
+ .hist { border:1px solid transparent; border-radius:8px; padding:8px 10px; margin:3px 0; cursor:pointer; }
88
+ .hist:hover { background:#0c0e12; }
89
+ .hist.active { background:#0c0e12; border-color:var(--accent); }
90
+ .hist .g { font-size:13.5px; line-height:1.4; } .hist .m { color:var(--dim); font-size:11px; margin-top:2px; }
78
91
  .empty { color:var(--dim); font-size:13px; }
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; }
80
- .wbname { flex:1; color:var(--accent); cursor:pointer; font-size:14px; }
92
+ .wbrow { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:8px; margin:1px 0; }
93
+ .wbrow:hover { background:#0c0e12; }
94
+ .wbname { flex:1; color:var(--accent); cursor:pointer; font-size:13.5px; }
81
95
  .wbmeta { color:var(--dim); font-size:12px; }
82
96
  .wbdel { cursor:pointer; opacity:.6; } .wbdel:hover { opacity:1; }
83
97
  .followup { margin-top:14px; padding-top:12px; border-top:1px solid var(--line); }
@@ -106,11 +120,14 @@
106
120
  <div class="wrap">
107
121
  <header>
108
122
  <h1>🪄 xitto 許願台</h1>
109
- <span class="sub">說出你想完成的事,交給它去做、做完給你成品</span>
123
+ <span class="sub">說出你想完成的事,做完給你成品</span>
110
124
  <span class="spacer"></span>
111
- <select id="space" title="專案/空間:不同專案的檔案與記憶各自獨立"></select>
112
- <button class="ghost" id="browsebtn" title="瀏覽並選一個真實資料夾(本地模式)" style="display:none">📁 選資料夾</button>
113
- <button class="ghost" id="newspace" title="新專案">+</button>
125
+ <div class="topctl">
126
+ <span class="prjlabel">專案</span>
127
+ <select id="space" title="專案/空間:不同專案的檔案與記憶各自獨立"></select>
128
+ <button class="ghost" id="browsebtn" title="瀏覽並選一個真實資料夾(本地模式)" style="display:none">📁 選</button>
129
+ <button class="ghost" id="newspace" title="新增一個專案/空間">+ 新專案</button>
130
+ </div>
114
131
  </header>
115
132
 
116
133
  <div id="fsmodal" class="modal" style="display:none">
@@ -132,6 +149,7 @@
132
149
  <div class="row">
133
150
  <select id="pack" title="領域"></select>
134
151
  <span class="spacer"></span>
152
+ <span class="askhint">⌘ / Ctrl + Enter 送出</span>
135
153
  <button id="go">交辦 →</button>
136
154
  </div>
137
155
  </div>
@@ -297,8 +315,9 @@ $("#go").onclick = async () => {
297
315
  $("#goal").value = "";
298
316
  $("#fview").style.display="none"; expandedLog=false;
299
317
  activeId = r.taskId;
300
- poll();
318
+ poll(); loadHistory();
301
319
  };
320
+ $("#goal").addEventListener("keydown", e=>{ if((e.metaKey||e.ctrlKey) && e.key==="Enter"){ e.preventDefault(); $("#go").click(); } });
302
321
 
303
322
  function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled"||s==="interrupted")?"error":""; }
304
323
  function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷",interrupted:"已中斷(重啟)"})[s]||s; }
@@ -317,6 +336,8 @@ async function poll() {
317
336
  }
318
337
 
319
338
  function renderCurrent(t) {
339
+ // 保住 steer 輸入框的內容與游標:輪詢每 1.2s 重繪整張卡,別把使用者正打到一半的補充洗掉
340
+ const ps=(()=>{const el=document.getElementById("steerin");return el?{v:el.value,f:document.activeElement===el,s:el.selectionStart}:null;})();
320
341
  const a = t.result?.artifacts, files = a ? [...(a.created||[]).map(f=>[f,"new"]), ...(a.modified||[]).map(f=>[f,"mod"])] : [];
321
342
  const p = t.progress||{}, nLog=(p.log||[]).length;
322
343
  const logSec = nLog ? `<div class="logtoggle" onclick="toggleLog()">${expandedLog?"▾ 收合過程":"▸ 展開過程("+nLog+" 步)"}</div>${expandedLog?`<div class="loglist">${logHtml(p)}</div>`:""}` : "";
@@ -329,6 +350,10 @@ function renderCurrent(t) {
329
350
  ${logSec}
330
351
  ${t.status==="needs-input"?`<div class="qbox"><div class="q">❓ ${esc(t.pending?.question)}</div>
331
352
  <input id="ans" placeholder="輸入你的回答,按 Enter 送出"></div>`:""}
353
+ ${t.status==="running"?`<div class="steerbox">
354
+ ${(p.steers||[]).slice(-3).map(s=>`<div class="steernote">✋ 已收到補充,會在下一步納入:${esc(s)}</div>`).join("")}
355
+ <input id="steerin" placeholder="想補充或調整方向?打字按 Enter——會在下一步納入,不中斷目前工作">
356
+ </div>`:""}
332
357
  ${t.status==="done"?`<div class="summary">${esc(t.result?.text||"")}</div>`:""}
333
358
  ${t.status==="error"?`<div class="summary">⚠ ${esc(t.error)}</div>`:""}
334
359
  ${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>`:""}
@@ -349,6 +374,17 @@ function renderCurrent(t) {
349
374
  poll();
350
375
  }};
351
376
  }
377
+ if (t.status==="running") {
378
+ const si = $("#steerin");
379
+ if (si) {
380
+ if (ps) { si.value = ps.v; if (ps.f) { si.focus(); try { si.setSelectionRange(ps.s, ps.s); } catch {} } } // 還原打到一半的內容/游標
381
+ si.onkeydown = async (e) => { if (e.key==="Enter" && si.value.trim()) {
382
+ const txt = si.value.trim(); si.value = ""; si.disabled = true;
383
+ await api("/v1/tasks/"+t.taskId+"/steer", { method:"POST", body: JSON.stringify({ text: txt }) });
384
+ si.disabled = false; si.focus();
385
+ }};
386
+ }
387
+ }
352
388
  }
353
389
 
354
390
  // 繼續/調整:送出接續上一個任務對話(sessionId)+ 同工作區的後續任務
@@ -363,11 +399,12 @@ async function submitFollowup(sessionId, pack, workspace){
363
399
  async function loadHistory() {
364
400
  const r = await api("/v1/tasks").then(r=>r.json());
365
401
  const list = (r.tasks||[]).filter(t=>t.mode==="goal" && (t.workspace||"default")===curSpace).reverse();
366
- $("#history").innerHTML = list.length ? list.map(t=>`<div class="hist" onclick="openTask('${t.taskId}')">
402
+ $("#history").innerHTML = list.length ? list.map(t=>`<div class="hist${t.taskId===activeId?' active':''}" onclick="openTask('${t.taskId}')">
367
403
  <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>
368
- <div class="m">${esc(t.createdAt)}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
404
+ <div class="m">${esc(fmtTime(t.createdAt))}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
369
405
  }
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"}); }
406
+ 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; } }
407
+ 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"}); }
371
408
 
372
409
  loadHistory(); loadFiles();
373
410
  </script>
@@ -442,6 +442,11 @@ export function createKernel(pack, config = {}) {
442
442
  let sameFeedback = 0;
443
443
  for (let round = 1; round <= maxRounds; round++) {
444
444
  opts.onRound?.({ round, maxRounds });
445
+ // 使用者中途補充(steering):把上一輪之間累積的補充折進這一輪指令(回合內的即時補充走 agent.steer)。
446
+ if (opts.drainSteer) {
447
+ const extra = opts.drainSteer();
448
+ if (extra && extra.length) instruction += '\n\n[使用者中途補充,請納入考量並據此調整]\n' + extra.map((s) => `- ${s}`).join('\n');
449
+ }
445
450
  const r = await api.runTurn(instruction, { history, onEvent: opts.onEvent, onAgent: opts.onAgent });
446
451
  history = r.messages;
447
452
  if (r.aborted) return { done: false, aborted: true, rounds: round, history };