xitto-kernel 0.3.0 → 0.3.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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3
4
+
5
+ - **背景任務 + 完成通知(非同步交互)**:server 新增「派任務→通知」形態,把 agent 當同事用。
6
+ - `POST /v1/tasks`:立刻回 `202 + taskId`,後台跑,完成 POST 結果到 `webhook`
7
+ - `GET /v1/tasks`、`GET /v1/tasks/:id`:列表 / 狀態 + 結果
8
+ - `GET /v1/tasks/:id/events`:附掛事件流(SSE,replay 緩衝 + 即時)
9
+ - 限流並發 `XITTO_SERVER_CONCURRENCY`(預設 2)
10
+ - 抽出 `createTaskStore`(純記憶體、可測)與 `mapEvent`;`/v1/run`/`/v1/stream` 共用 `runKernel`
11
+ - 5 個任務佇列測試(狀態轉移 / 限流 / 事件緩衝 / 完成回呼 / 訂閱)
12
+
13
+ ## 0.3.2
14
+
15
+ - **沒設定就啟動 → 直接進導引**:偵測到沒有 providers.json 且在真實終端時,
16
+ 不再只印提示,而是直接帶進 `init` 設定流程,完成後接續啟動該 pack(非 TTY 仍只給提示)。
17
+
18
+ ## 0.3.1
19
+
20
+ 首次使用導引 —— 不再假設使用者已有 xitto-code。
21
+
22
+ ### 新增
23
+
24
+ - **`xitto-kernel init`**:互動式設定導引,產生 `~/.xitto-code/providers.json`
25
+ - 內建 provider 範本(MiniMax / Anthropic / OpenAI / DeepSeek / 自訂)
26
+ - 引導選 provider → 填 model → 處理 API key(環境變數參照 `${NAME}` 不落地,或內嵌)
27
+ - 既有設定不覆寫;`--force` 合併新 provider
28
+ - pipe-safe 逐行讀取(可 `echo answers | xitto-kernel init` 腳本化)
29
+ - **沒設定就啟動**:改丟明確提示,引導跑 `xitto-kernel init`(不再叫人去找 xitto-code 的範例檔)
30
+ - README 快速開始改為 安裝 → `init` → 啟動 三步
31
+
3
32
  ## 0.3.0
4
33
 
5
34
  把底座的「能力」與「體驗」補到接近 Claude Code,並擴充領域 pack 與評測。
package/README.md CHANGED
@@ -23,20 +23,26 @@ xitto-code 經掃描後,約 **8 成已是領域無關的 kernel**;真正跟
23
23
 
24
24
  ## 快速開始
25
25
 
26
- **前置需求**
27
- - Node.js ≥ 20
28
- - `~/.xitto-code/providers.json` —— LLM provider 設定(與 xitto-code 共用,內含 API key)。
29
- 沒有的話,複製一份填入 key 即可(格式見 xitto-code 的 `providers.example.json`)。
26
+ **前置需求**:Node.js ≥ 20
30
27
 
31
- **安裝**(已發佈 npm)
28
+ **1. 安裝**(已發佈 npm)
32
29
  ```bash
33
30
  npm install -g xitto-kernel # 全域命令 xitto-kernel
34
31
  ```
35
32
  > 開發本倉庫:`cd xitto-kernel && npm install && npm link`。
36
33
 
37
- **跑內建 pack(互動 CLI)**
34
+ **2. 首次設定**(互動導引,產生 `~/.xitto-code/providers.json`)
35
+ ```bash
36
+ xitto-kernel init
37
+ ```
38
+ 引導你選 provider(MiniMax / Anthropic / OpenAI / DeepSeek / 自訂)→ 填 model →
39
+ 設定 API key(建議用環境變數參照 `${NAME}`,金鑰不落地)。已是 xitto-code 使用者可直接共用既有設定、跳過此步。
40
+ (沒設定就啟動會提示你跑 `init`;既有設定不會被覆寫,`--force` 才會合併新 provider。)
41
+
42
+ **3. 跑內建 pack(互動 CLI)**
38
43
  ```bash
39
44
  xitto-kernel # coding agent(讀寫檔案、跑命令)
45
+ xitto-kernel --tui # 完整 Ink TUI(持久狀態列、串流、Esc 中斷;需真實終端)
40
46
  xitto-kernel --pack notes # 筆記 / 知識庫 agent
41
47
  xitto-kernel --pack data-query
42
48
  xitto-kernel --sandbox # 啟動就開 Seatbelt 沙箱
@@ -66,6 +72,20 @@ curl -s -XPOST localhost:8787/v1/run -H "Authorization: Bearer secret" \
66
72
  結構化 JSON 日誌(審計/觀測)、6 個 pack 可選、JSON 或 SSE(`/v1/stream`)串流。
67
73
  「個人 vs 生產」是 **app 層**的事 —— 同一個 kernel,CLI 與 server 是兩個 app。
68
74
 
75
+ **背景任務 + 完成通知(非同步交互)** —— 派任務出去、立刻拿到 `taskId`、做完回呼 webhook,不用一直盯著:
76
+ ```bash
77
+ # 派任務(立刻回 202 + taskId),完成時 POST 結果到 webhook
78
+ curl -s -XPOST localhost:8787/v1/tasks -H "Authorization: Bearer secret" \
79
+ -H content-type:application/json \
80
+ -d '{"pack":"general","mode":"goal","goal":"...","webhook":"https://你的服務/done"}'
81
+
82
+ curl -s localhost:8787/v1/tasks -H "Authorization: Bearer secret" # 列表
83
+ curl -s localhost:8787/v1/tasks/<id> -H "Authorization: Bearer secret" # 狀態 + 結果
84
+ curl -sN localhost:8787/v1/tasks/<id>/events -H "Authorization: Bearer secret" # 附掛事件流(SSE,replay+即時)
85
+ ```
86
+ 限流並發 `XITTO_SERVER_CONCURRENCY`(預設 2);webhook 完成時收到 `{taskId,status,text,usage,rounds,done}`。
87
+ 這把「即時盯著看」延伸到「派任務→通知」的非同步形態(像把 agent 當同事)。
88
+
69
89
  ## 做你自己的領域 agent(不固化)
70
90
 
71
91
  kernel 是**被依賴的套件**,不是被 clone 的範本。你的 agent 是獨立小專案:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
@@ -0,0 +1,134 @@
1
+ // 首次啟動導引:互動式產生 ~/.xitto-code/providers.json。
2
+ // 內建常見 provider 範本(MiniMax / Anthropic / OpenAI / DeepSeek / 自訂),
3
+ // 引導選 provider → 填 model → 處理 API key(環境變數或內嵌),寫檔不覆寫既有設定。
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join, dirname } from 'node:path';
7
+ import { createInterface } from 'node:readline';
8
+
9
+ const e = (n) => (s) => `\x1b[${n}m${s}\x1b[0m`;
10
+ const green = e(32); const gray = e(90); const cyan = e(36); const yellow = e(33); const bold = e(1); const red = e(31);
11
+
12
+ // provider 範本(沿用實測可用的格式;baseUrl/api/model 皆可在引導中改)
13
+ const PRESETS = {
14
+ minimax: { label: 'MiniMax(M2.7,anthropic 相容)', api: 'anthropic-messages', baseUrl: 'https://api.minimaxi.com/anthropic', env: 'MINIMAX_API_KEY', model: { id: 'MiniMax-M2.7', name: 'MiniMax M2.7', contextWindow: 1000192, maxTokens: 131072 } },
15
+ anthropic: { label: 'Anthropic Claude', api: 'anthropic-messages', baseUrl: 'https://api.anthropic.com', env: 'ANTHROPIC_API_KEY', model: { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextWindow: 200000, maxTokens: 64000 } },
16
+ openai: { label: 'OpenAI', api: 'openai-completions', baseUrl: 'https://api.openai.com/v1', env: 'OPENAI_API_KEY', model: { id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000, maxTokens: 16384 } },
17
+ deepseek: { label: 'DeepSeek', api: 'openai-completions', baseUrl: 'https://api.deepseek.com', env: 'DEEPSEEK_API_KEY', model: { id: 'deepseek-chat', name: 'DeepSeek Chat', contextWindow: 64000, maxTokens: 8192 } },
18
+ custom: { label: '自訂(手動填全部)', api: 'openai-completions', baseUrl: '', env: 'LLM_API_KEY', model: { id: '', name: '', contextWindow: 32000, maxTokens: 4096 } },
19
+ };
20
+
21
+ const PATH = () => process.env.XITTO_CODE_CONFIG || join(homedir(), '.xitto-code', 'providers.json');
22
+
23
+ export { PRESETS };
24
+
25
+ // 純函數:把答案組成 providers.json 結構(合併既有 providers)。互動殼與測試共用。
26
+ export function buildConfig(a, existing) {
27
+ const cfg = existing && existing.providers ? existing : { defaultModel: a.modelId, providers: {} };
28
+ cfg.providers = cfg.providers || {};
29
+ cfg.providers[a.providerName] = {
30
+ baseUrl: a.baseUrl, apiKey: a.apiKey, api: a.api,
31
+ models: [{ id: a.modelId, name: a.modelName || a.modelId, contextWindow: a.contextWindow, maxTokens: a.maxTokens }],
32
+ };
33
+ cfg.defaultModel = a.modelId;
34
+ return cfg;
35
+ }
36
+
37
+ // pipe-safe 逐行讀取:把 'line' 事件排入佇列,即使管線一次送進整批輸入也能依序消費
38
+ // (readline/promises 的 question() 在非 TTY 管線下會丟失已緩衝的行)。
39
+ function makeAsker() {
40
+ const rl = createInterface({ input: process.stdin });
41
+ const queue = []; const waiters = []; let closed = false;
42
+ rl.on('line', (l) => { const w = waiters.shift(); if (w) w(l); else queue.push(l); });
43
+ rl.on('close', () => { closed = true; while (waiters.length) waiters.shift()(null); });
44
+ const nextLine = () => new Promise((res) => { if (queue.length) res(queue.shift()); else if (closed) res(null); else waiters.push(res); });
45
+ return {
46
+ close: () => rl.close(),
47
+ ask: async (q, def) => {
48
+ process.stdout.write(gray(q + (def ? ` [${def}]` : '') + ' '));
49
+ const line = await nextLine();
50
+ return ((line == null ? '' : line).trim()) || def || '';
51
+ },
52
+ };
53
+ }
54
+
55
+ export async function runInit(argv = []) {
56
+ const force = argv.includes('--force');
57
+ const path = PATH();
58
+ const io = makeAsker();
59
+ const rl = { close: io.close };
60
+ const ask = io.ask;
61
+
62
+ try {
63
+ console.log('\n' + bold('🚀 xitto-kernel 首次設定') + gray(' —— 建立 LLM provider 設定'));
64
+ console.log(gray(`設定檔:${path}\n`));
65
+
66
+ // 既有檔保護
67
+ if (existsSync(path) && !force) {
68
+ console.log(yellow(`已存在設定檔。`) + gray(' 用 `xitto-kernel init --force` 覆寫,或直接編輯該檔。'));
69
+ let cfg; try { cfg = JSON.parse(readFileSync(path, 'utf8')); } catch { /* 壞檔忽略 */ }
70
+ if (cfg) {
71
+ const names = Object.keys(cfg.providers || {});
72
+ console.log(gray(` 目前 provider:${names.join(', ') || '(無)'} 預設 model:${cfg.defaultModel || '(未設)'}`));
73
+ }
74
+ rl.close();
75
+ return;
76
+ }
77
+
78
+ // 1) 選 provider
79
+ const keys = Object.keys(PRESETS);
80
+ console.log(bold('1) 選 LLM provider:'));
81
+ keys.forEach((k, i) => console.log(` ${cyan(String(i + 1))}. ${PRESETS[k].label}`));
82
+ const pick = await ask('輸入編號', '1');
83
+ const presetKey = keys[(parseInt(pick, 10) || 1) - 1] || 'minimax';
84
+ const preset = PRESETS[presetKey];
85
+ console.log(green(` → ${preset.label}\n`));
86
+
87
+ // 2) 連線 / model
88
+ console.log(bold('2) 連線與 model:'));
89
+ const providerName = await ask('provider 名稱(providers.json 的鍵)', presetKey);
90
+ const baseUrl = await ask('baseUrl', preset.baseUrl);
91
+ const api = await ask('api 型別(anthropic-messages | openai-completions)', preset.api);
92
+ const modelId = await ask('model id', preset.model.id);
93
+ const modelName = await ask('model 顯示名', preset.model.name || modelId);
94
+ const contextWindow = parseInt(await ask('contextWindow', String(preset.model.contextWindow)), 10) || preset.model.contextWindow;
95
+ const maxTokens = parseInt(await ask('maxTokens', String(preset.model.maxTokens)), 10) || preset.model.maxTokens;
96
+ if (!modelId) { console.log(red('\nmodel id 不可空,已取消。')); rl.close(); return; }
97
+
98
+ // 3) API key:環境變數(建議)或內嵌
99
+ console.log('\n' + bold('3) API key:'));
100
+ console.log(gray(' a) 用環境變數參照(建議,金鑰不落地在設定檔)'));
101
+ console.log(gray(' b) 現在貼上金鑰(直接存進設定檔,檔案在你家目錄)'));
102
+ const mode = (await ask('選 a 或 b', 'a')).toLowerCase();
103
+ let apiKey; let envHint = '';
104
+ if (mode === 'b') {
105
+ const k = await ask('貼上 API key', '');
106
+ apiKey = k;
107
+ if (!k) console.log(yellow(' (未填,稍後請手動補上 apiKey)'));
108
+ } else {
109
+ const envName = await ask('環境變數名', preset.env);
110
+ apiKey = '${' + envName + '}';
111
+ envHint = envName;
112
+ }
113
+
114
+ // 組設定(保留既有 providers,--force 時合併)
115
+ let existing; if (existsSync(path)) { try { existing = JSON.parse(readFileSync(path, 'utf8')); } catch { /* 壞檔重建 */ } }
116
+ const cfg = buildConfig({ providerName, baseUrl, api, modelId, modelName, contextWindow, maxTokens, apiKey }, existing);
117
+
118
+ mkdirSync(dirname(path), { recursive: true });
119
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
120
+
121
+ console.log('\n' + green('✓ 已寫入 ') + path);
122
+ if (envHint) {
123
+ console.log('\n' + bold('下一步:設定環境變數(金鑰)'));
124
+ console.log(gray(` export ${envHint}="你的金鑰" # 加進 ~/.zshrc 永久生效`));
125
+ }
126
+ console.log('\n' + bold('啟動:'));
127
+ console.log(green(' xitto-kernel') + gray(' # coding pack,互動對話'));
128
+ console.log(green(' xitto-kernel --tui') + gray(' # 完整 Ink TUI(真實終端)'));
129
+ console.log(green(' xitto-kernel --pack general') + gray(' # 通用 agent'));
130
+ console.log('');
131
+ } finally {
132
+ rl.close();
133
+ }
134
+ }
package/src/app/main.js CHANGED
@@ -5,6 +5,7 @@ import { loadModel } from './providers.js';
5
5
  import { runCli } from './cli.js';
6
6
  import { runTui } from './tui-run.js';
7
7
  import { newAgent } from './scaffold.js';
8
+ import { runInit } from './init.js';
8
9
  import { createKernel } from '../kernel/index.js';
9
10
  import { loadMcpTools } from '../kernel/mcp.js';
10
11
  import { createCodingPack } from '../packs/coding/index.js';
@@ -27,6 +28,9 @@ const PACKS = {
27
28
  };
28
29
 
29
30
  export async function main(argv = process.argv.slice(2)) {
31
+ // 子指令:init —— 首次設定導引,產生 providers.json
32
+ if (argv[0] === 'init') { await runInit(argv.slice(1)); return; }
33
+
30
34
  // 子指令:new-agent <name> —— 產出獨立 agent 專案(不碰 kernel)
31
35
  if (argv[0] === 'new-agent') {
32
36
  const name = argv.find((a, i) => i >= 1 && !a.startsWith('--'));
@@ -50,7 +54,26 @@ export async function main(argv = process.argv.slice(2)) {
50
54
 
51
55
  let model, getApiKey;
52
56
  try { ({ model, getApiKey } = loadModel(opts.model)); }
53
- catch (err) { console.error('\x1b[31m' + err.message + '\x1b[0m'); process.exit(1); }
57
+ catch (err) {
58
+ // 沒設定 + 真實終端:直接帶進設定導引,完成後續跑;非 TTY 才只給提示
59
+ if (err.noConfig && process.stdin.isTTY) {
60
+ console.log(cyan('首次使用,沒找到 providers.json —— 進入設定導引。') + gray('(按 Ctrl+C 取消)'));
61
+ await runInit([]);
62
+ try { ({ model, getApiKey } = loadModel(opts.model)); }
63
+ catch (err2) {
64
+ console.error(red(err2.message));
65
+ console.error(gray(err2.noConfig ? '(未完成設定,已取消)' : '(設定好像缺東西,可編輯該檔或重跑 `xitto-kernel init`)'));
66
+ process.exit(1);
67
+ }
68
+ } else {
69
+ console.error(red(err.message));
70
+ if (err.noConfig) {
71
+ console.error('\n' + cyan('首次使用?') + ' 跑一次設定導引:');
72
+ console.error(green(' xitto-kernel init') + gray(' # 選 provider、填 model、設定 API key'));
73
+ }
74
+ process.exit(1);
75
+ }
76
+ }
54
77
 
55
78
  // MCP:啟動時連 .xitto-kernel/<pack>/mcp.json 的 server,工具以 extraTools 注入
56
79
  const cwd = process.cwd();
@@ -112,6 +135,7 @@ function printHelp() {
112
135
  'xitto-kernel — 領域無關 agent 底座',
113
136
  '',
114
137
  '用法:',
138
+ ' xitto-kernel init 首次設定導引(產生 providers.json)',
115
139
  ' xitto-kernel [--pack <name>] [--model <id>] [--sandbox] [--resume [id]] [--yes] 互動跑內建 pack',
116
140
  ' xitto-kernel --pack general --goal "..." [--yes] 目標驅動自主循環(headless)',
117
141
  ' xitto-kernel new-agent <name> 產出依賴 kernel 的獨立 agent 專案',
@@ -125,7 +149,7 @@ function printHelp() {
125
149
  ' --yes, -y 自動核准 mutating 工具(headless / 自主循環常用)',
126
150
  ' --help 顯示說明',
127
151
  '',
128
- '需要 ~/.xitto-code/providers.json(與 xitto-code 共用)。',
152
+ '首次使用先跑 `xitto-kernel init` 建立 ~/.xitto-code/providers.json(已是 xitto-code 使用者可直接共用)。',
129
153
  'new-agent 產出的是獨立專案,import xitto-kernel 而非修改它——升級不固化。',
130
154
  ].join('\n'));
131
155
  }
@@ -7,7 +7,7 @@ import { join } from 'node:path';
7
7
  const DEFAULT_PATH = () => process.env.XITTO_CODE_CONFIG || join(homedir(), '.xitto-code', 'providers.json');
8
8
 
9
9
  export function loadProvidersConfig(path = DEFAULT_PATH()) {
10
- if (!existsSync(path)) throw new Error(`找不到 providers.json:${path}\n(可複用 xitto-code ~/.xitto-code/providers.json)`);
10
+ if (!existsSync(path)) { const err = new Error(`找不到 providers.json:${path}`); err.noConfig = true; throw err; }
11
11
  return { ...JSON.parse(readFileSync(path, 'utf8')), path };
12
12
  }
13
13
 
package/src/app/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Server app(PoC)— 把 kernel 包成 HTTP 服務(零依賴 node:http)。
2
2
  // 證明 kernel 能脫離 CLI 跑成服務:bearer token 認證、per-session 隔離工作目錄、沙箱、結構化日誌、
3
- // JSON 或 SSE 串流。這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
3
+ // JSON 或 SSE 串流,以及「背景任務 + 完成通知(webhook)」—— 派任務出去、做完回呼,不用一直盯著。
4
+ // 這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
4
5
  import { createServer } from 'node:http';
5
6
  import { mkdirSync } from 'node:fs';
6
7
  import { join } from 'node:path';
@@ -20,7 +21,75 @@ const PACKS = {
20
21
  };
21
22
 
22
23
  const lastText = (history) => ([...(history || [])].reverse().find((m) => m.role === 'assistant')?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
23
- const newId = () => 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
24
+ const newId = (p = 's') => p + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
25
+
26
+ // 把原始 kernel 事件壓成精簡的對外事件(串流端與背景任務共用,避免重複映射)
27
+ export const mapEvent = (ev) => {
28
+ if (ev.type === 'tool_execution_start') return { type: 'tool', name: ev.toolName, args: ev.args };
29
+ if (ev.type === 'tool_execution_end') return { type: 'tool_end', name: ev.toolName, isError: !!ev.isError };
30
+ if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') return { type: 'text', delta: ev.assistantMessageEvent.delta };
31
+ return null;
32
+ };
33
+
34
+ /**
35
+ * 背景任務佇列(純記憶體、可測,與 HTTP 無關)。
36
+ * 派任務 → 限流跑 → 緩衝事件供事後附掛 → 完成回呼(webhook)。
37
+ * @param {Object} o
38
+ * @param {(spec:object, emit:(ev:object)=>void)=>Promise<any>} o.runJob 實際執行(回傳值=任務結果)
39
+ * @param {number} [o.concurrency] 同時跑幾個(預設 2)
40
+ * @param {(task:object)=>void} [o.onFinish] 每個任務 settle 後呼叫(拿來發 webhook)
41
+ * @param {number} [o.maxEvents] 每任務保留最近幾筆事件(預設 500)
42
+ */
43
+ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents = 500 } = {}) {
44
+ const tasks = new Map(); // id -> task
45
+ const queue = []; // 等待中的 task
46
+ const subs = new Map(); // id -> Set<(ev)=>void>
47
+ let active = 0;
48
+
49
+ const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', sessionId: t.result?.sessionId || t.spec.sessionId || null, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error });
50
+
51
+ const emit = (t, ev) => {
52
+ t.events.push(ev);
53
+ if (t.events.length > maxEvents) t.events.shift();
54
+ const s = subs.get(t.id); if (s) for (const fn of s) { try { fn(ev); } catch { /* 訂閱端錯不影響任務 */ } }
55
+ };
56
+
57
+ function pump() {
58
+ while (active < concurrency && queue.length) {
59
+ const t = queue.shift();
60
+ active++;
61
+ t.status = 'running'; t.startedAt = new Date().toISOString();
62
+ emit(t, { type: 'status', status: 'running' });
63
+ Promise.resolve()
64
+ .then(() => runJob(t.spec, (ev) => emit(t, ev)))
65
+ .then((result) => { t.status = 'done'; t.result = result; })
66
+ .catch((e) => { t.status = 'error'; t.error = e.message || String(e); })
67
+ .finally(() => {
68
+ t.finishedAt = new Date().toISOString();
69
+ emit(t, { type: 'end', status: t.status, result: t.result, error: t.error });
70
+ active--;
71
+ try { onFinish?.(t); } catch { /* webhook 錯不影響佇列 */ }
72
+ pump();
73
+ });
74
+ }
75
+ }
76
+
77
+ return {
78
+ enqueue(spec) {
79
+ const t = { id: newId('t'), status: 'queued', spec: spec || {}, events: [], result: null, error: null, createdAt: new Date().toISOString(), startedAt: null, finishedAt: null };
80
+ tasks.set(t.id, t);
81
+ queue.push(t);
82
+ pump();
83
+ return t;
84
+ },
85
+ get: (id) => tasks.get(id),
86
+ view: (id) => { const t = tasks.get(id); return t ? view(t) : null; },
87
+ result: (id) => { const t = tasks.get(id); return t ? { ...view(t), result: t.result } : null; },
88
+ list: () => [...tasks.values()].map(view),
89
+ subscribe(id, fn) { let s = subs.get(id); if (!s) { s = new Set(); subs.set(id, s); } s.add(fn); return () => s.delete(fn); },
90
+ stats: () => ({ active, queued: queue.length, total: tasks.size }),
91
+ };
92
+ }
24
93
 
25
94
  /**
26
95
  * @param {Object} o
@@ -29,9 +98,10 @@ const newId = () => 's' + Date.now().toString(36) + Math.random().toString(36).s
29
98
  * @param {string} [o.token] bearer token(未設=不驗證,僅 PoC)
30
99
  * @param {string} [o.baseDir] 每個 session 的隔離工作目錄根
31
100
  * @param {boolean} [o.sandbox] 是否沙箱(預設 true:服務端跑 agent 應隔離)
101
+ * @param {number} [o.concurrency] 背景任務同時數(預設 2)
32
102
  * @returns {import('node:http').Server}
33
103
  */
34
- export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true } = {}) {
104
+ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2 } = {}) {
35
105
  const sessions = new Map(); // sessionId -> { pack, history }
36
106
  mkdirSync(baseDir, { recursive: true });
37
107
 
@@ -39,49 +109,93 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
39
109
  const authed = (req) => !token || (req.headers.authorization === `Bearer ${token}`);
40
110
  const log = (o) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...o }));
41
111
  const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; if (b.length > 1e6) req.destroy(); }); req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } }); });
112
+ const sseHead = (res) => res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
113
+
114
+ // 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件
115
+ async function runKernel(spec, onEvent) {
116
+ const make = PACKS[spec.pack || 'general'];
117
+ if (!make) throw new Error(`未知 pack「${spec.pack}」,可用:${Object.keys(PACKS).join(', ')}`);
118
+ const sessionId = spec.sessionId || newId();
119
+ const sess = sessions.get(sessionId) || { pack: spec.pack || 'general', history: [] };
120
+ const workdir = join(baseDir, sessionId); mkdirSync(workdir, { recursive: true });
121
+ const kernel = createKernel(make({ cwd: workdir }), { cwd: workdir, model, getApiKey, sandbox: { enabled: sandbox }, getSandbox: () => sandbox, confirm: async () => 'yes' });
122
+ const usage = { input: 0, output: 0 };
123
+ 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); };
124
+ const r = (spec.mode === 'goal')
125
+ ? await kernel.runGoal(spec.goal || spec.input || '', { history: sess.history, onEvent: wrapped })
126
+ : await kernel.runTurn(spec.input || '', { history: sess.history, onEvent: wrapped });
127
+ sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
128
+ return { sessionId, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
129
+ }
130
+
131
+ // 完成通知:POST 結果到 spec.webhook(http/https),單次嘗試、失敗記日誌不重試(PoC)
132
+ async function fireWebhook(task) {
133
+ const url = task.spec.webhook; if (!url || !/^https?:\/\//.test(url)) return;
134
+ const r = task.result || {};
135
+ const body = JSON.stringify({ taskId: task.id, status: task.status, error: task.error, sessionId: r.sessionId, text: r.text, usage: r.usage, rounds: r.rounds, done: r.done, finishedAt: task.finishedAt });
136
+ try { const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body }); log({ webhook: url, task: task.id, status: task.status, code: resp.status }); }
137
+ catch (e) { log({ webhook: url, task: task.id, error: e.message }); }
138
+ }
139
+
140
+ const tasks = createTaskStore({
141
+ concurrency,
142
+ runJob: (spec, emit) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }),
143
+ 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); },
144
+ });
42
145
 
43
146
  return createServer(async (req, res) => {
44
147
  const url = new URL(req.url, 'http://localhost');
45
- if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { ok: true, packs: Object.keys(PACKS), model: model.id });
148
+ const path = url.pathname;
149
+ if (req.method === 'GET' && path === '/health') return json(res, 200, { ok: true, packs: Object.keys(PACKS), model: model.id, tasks: tasks.stats() });
46
150
  if (!authed(req)) return json(res, 401, { error: 'unauthorized(帶 Authorization: Bearer <token>)' });
47
151
 
48
- if (req.method === 'POST' && (url.pathname === '/v1/run' || url.pathname === '/v1/stream')) {
152
+ // 同步:跑完才回(JSON SSE 串流)
153
+ if (req.method === 'POST' && (path === '/v1/run' || path === '/v1/stream')) {
49
154
  const body = await readBody(req);
50
- const make = PACKS[body.pack || 'general'];
51
- if (!make) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
52
- const sessionId = body.sessionId || newId();
53
- const sess = sessions.get(sessionId) || { pack: body.pack || 'general', history: [] };
54
- const workdir = join(baseDir, sessionId); mkdirSync(workdir, { recursive: true });
55
- const kernel = createKernel(make({ cwd: workdir }), { cwd: workdir, model, getApiKey, sandbox: { enabled: sandbox }, getSandbox: () => sandbox, confirm: async () => 'yes' });
56
-
57
- const usage = { input: 0, output: 0 };
58
- const streaming = url.pathname === '/v1/stream';
59
- if (streaming) res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
155
+ const streaming = path === '/v1/stream';
156
+ if (streaming) sseHead(res);
60
157
  const sse = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
61
- const onEvent = (ev) => {
62
- if (ev.type === 'message_end' && ev.message?.usage) { usage.input += ev.message.usage.input || 0; usage.output += ev.message.usage.output || 0; }
63
- if (!streaming) return;
64
- if (ev.type === 'tool_execution_start') sse({ type: 'tool', name: ev.toolName, args: ev.args });
65
- else if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') sse({ type: 'text', delta: ev.assistantMessageEvent.delta });
66
- };
67
-
68
158
  const t0 = Date.now();
69
159
  try {
70
- const r = (body.mode === 'goal')
71
- ? await kernel.runGoal(body.goal || body.input || '', { history: sess.history, onEvent })
72
- : await kernel.runTurn(body.input || '', { history: sess.history, onEvent });
73
- sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
74
- const text = r.text ?? lastText(sess.history);
75
- log({ pack: sess.pack, session: sessionId, mode: body.mode || 'turn', tokens: usage.input + usage.output, rounds: r.rounds, ms: Date.now() - t0 });
76
- const payload = { sessionId, text, usage, rounds: r.rounds, done: r.done };
77
- if (streaming) { sse({ type: 'done', ...payload }); res.end(); }
78
- else json(res, 200, payload);
160
+ const r = await runKernel(body, streaming ? (ev) => { const m = mapEvent(ev); if (m) sse(m); } : undefined);
161
+ log({ pack: body.pack || 'general', session: r.sessionId, mode: body.mode || 'turn', tokens: r.usage.input + r.usage.output, rounds: r.rounds, ms: Date.now() - t0 });
162
+ if (streaming) { sse({ type: 'done', ...r }); res.end(); } else json(res, 200, r);
79
163
  } catch (e) {
80
- log({ pack: sess.pack, session: sessionId, error: e.message });
81
- if (streaming) { sse({ type: 'error', error: e.message }); res.end(); } else json(res, 500, { error: e.message });
164
+ log({ pack: body.pack, error: e.message });
165
+ if (streaming) { sse({ type: 'error', error: e.message }); res.end(); } else json(res, e.message?.startsWith('未知 pack') ? 400 : 500, { error: e.message });
82
166
  }
83
167
  return;
84
168
  }
169
+
170
+ // 背景任務:立刻回 taskId,後台跑,完成發 webhook
171
+ if (req.method === 'POST' && path === '/v1/tasks') {
172
+ const body = await readBody(req);
173
+ if (!PACKS[body.pack || 'general']) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
174
+ if (body.webhook && !/^https?:\/\//.test(body.webhook)) return json(res, 400, { error: 'webhook 需為 http(s) URL' });
175
+ const t = tasks.enqueue({ pack: body.pack, mode: body.mode, input: body.input, goal: body.goal, sessionId: body.sessionId, webhook: body.webhook });
176
+ log({ task: t.id, action: 'enqueue', pack: body.pack || 'general', mode: body.mode || 'turn' });
177
+ return json(res, 202, { taskId: t.id, status: t.status, ...tasks.stats() });
178
+ }
179
+ if (req.method === 'GET' && path === '/v1/tasks') return json(res, 200, { tasks: tasks.list(), ...tasks.stats() });
180
+
181
+ // 任務狀態 / 結果
182
+ const mTask = path.match(/^\/v1\/tasks\/([^/]+)$/);
183
+ if (req.method === 'GET' && mTask) { const v = tasks.result(mTask[1]); return v ? json(res, 200, v) : json(res, 404, { error: 'task not found' }); }
184
+
185
+ // 附掛背景任務的事件流(replay 緩衝 + 即時;已結束則回放後關閉)
186
+ const mEv = path.match(/^\/v1\/tasks\/([^/]+)\/events$/);
187
+ if (req.method === 'GET' && mEv) {
188
+ const task = tasks.get(mEv[1]);
189
+ if (!task) return json(res, 404, { error: 'task not found' });
190
+ sseHead(res);
191
+ const sse = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
192
+ for (const ev of task.events) sse(ev);
193
+ if (task.status === 'done' || task.status === 'error') { res.end(); return; }
194
+ const unsub = tasks.subscribe(task.id, (ev) => { sse(ev); if (ev.type === 'end') { try { unsub(); } catch { /* 略 */ } res.end(); } });
195
+ req.on('close', () => { try { unsub(); } catch { /* 略 */ } });
196
+ return;
197
+ }
198
+
85
199
  json(res, 404, { error: 'not found' });
86
200
  });
87
201
  }
@@ -90,11 +204,13 @@ export function startServer() {
90
204
  const port = Number(process.env.PORT || 8787);
91
205
  const token = process.env.XITTO_SERVER_TOKEN || 'dev-token';
92
206
  const sandbox = process.env.XITTO_SERVER_SANDBOX !== 'off';
207
+ const concurrency = Number(process.env.XITTO_SERVER_CONCURRENCY || 2);
93
208
  const { model, getApiKey } = loadModel(process.env.XITTO_MODEL);
94
- const server = createServerApp({ model, getApiKey, token, sandbox });
209
+ const server = createServerApp({ model, getApiKey, token, sandbox, concurrency });
95
210
  server.listen(port, () => {
96
- console.log(`xitto-kernel server · http://localhost:${port} · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'}`);
211
+ console.log(`xitto-kernel server · http://localhost:${port} · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}`);
97
212
  console.log(`token: ${token === 'dev-token' ? 'dev-token(請設 XITTO_SERVER_TOKEN)' : '(已設定)'}`);
213
+ console.log('路由:POST /v1/run · /v1/stream · /v1/tasks(背景+webhook)|GET /v1/tasks[/:id[/events]] · /health');
98
214
  });
99
215
  return server;
100
216
  }