xitto-kernel 0.8.4 → 0.8.5

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.5
4
+
5
+ - **許願台重啟後歷史還在(持久化)**:原本任務清單與對話 session 都是 in-memory,重啟全沒。改成落地:
6
+ - **任務清單** → `.xitto-server/tasks/<id>.json`(每任務一檔,狀態變更時覆寫),啟動載回 → **歷史成品重啟後自動顯示**
7
+ - **對話 session** → `.xitto-server/sessions/<id>.json`,啟動載回 → **重啟後仍能「繼續/調整」**(對話脈絡跨重啟)
8
+ - **重啟收尾**:載入時還停在 `running`/`queued`/`needs-input` 的(agent 已隨進程消失)標 `interrupted`「已中斷(重啟)」
9
+ - 對標 Claude Code「對話自動落地」;但許願台是**自動顯示歷史**(成品清單),非明確 `--resume`(它是 chat,單位不同)
10
+ - 1 個新測試(落地/載回/interrupted)+ 真實端到端:跑任務→重啟(新 server 同 baseDir)→歷史顯示 + 接續對話寫出「重啟前只在對話講過的偏好 42」。測試 189/189
11
+
3
12
  ## 0.8.4
4
13
 
5
14
  - **桌面雙欄佈局(善用寬螢幕)**:原本單條 760px 窄欄、左右大量留白。改用 CSS grid 雙欄:
package/README.md CHANGED
@@ -118,6 +118,8 @@ npm run serve:local # = LOCAL=1 SANDBOX=off,token 預設
118
118
 
119
119
  **本地就地模式(像 Claude Code 改你選的真實資料夾)**:`XITTO_SERVER_LOCAL=1` 時,網頁多一個「**📁 選資料夾**」鈕——**用點的**從家目錄瀏覽進你的真實資料夾並選定(不用打路徑;瀏覽器拿不到絕對路徑,所以由 local server 端列資料夾),或「新專案」直接貼絕對路徑也行。任務就**就地改那個資料夾的檔**(不另開隔離副本),工作台列的也是它。這把「許願台(隔離,服務非技術使用者)」和「Claude Code(就地,改你現有的 codebase)」兩個模型打通:**本機自用想就地 → 給路徑;隔離/託管 → 給名稱**。**安全**:只在 `local` 模式才認絕對路徑;**託管模式收到絕對路徑會被消毒成管理空間,不會逃逸到主機任意路徑**。
120
120
 
121
+ **重啟後歷史還在(持久化)**:任務清單落地 `.xitto-server/tasks/`、對話 session 落地 `.xitto-server/sessions/`,啟動時載回——所以**重啟後歷史成品自動顯示、舊成品仍能「繼續/調整」**(對話脈絡也在)。重啟時還在跑/待答的任務會標「已中斷(重啟)」。對標 Claude Code「對話自動落地」,但許願台是**自動顯示歷史**(成品清單),而非 Claude Code 的明確 `--resume`。
122
+
121
123
  **溯源/檔案位置**:成品記錄它的**邏輯位置(workspace)**;**實體絕對路徑**預設不外露(託管不洩漏伺服器路徑),只在**本地模式**(`XITTO_SERVER_LOCAL=1`)才在成品附「📂 檔案位置」供你到 Finder/Explorer 找檔。
122
124
 
123
125
  零依賴單一 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.8.5",
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; } };
@@ -251,13 +273,13 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
251
273
  if (spec.mode === 'goal') {
252
274
  // 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
253
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 }) });
254
- sess.history = o.history || []; sessions.set(sessionId, sess);
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
  });
@@ -315,8 +315,8 @@ $("#go").onclick = async () => {
315
315
  poll();
316
316
  };
317
317
 
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; }
318
+ function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled"||s==="interrupted")?"error":""; }
319
+ function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷",interrupted:"已中斷(重啟)"})[s]||s; }
320
320
  const CANCELLABLE = ["queued","running","needs-input"];
321
321
  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
322
  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;} } }