xitto-kernel 0.1.0 → 0.3.0
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 +61 -0
- package/README.md +51 -9
- package/package.json +18 -4
- package/src/app/cli.js +71 -5
- package/src/app/index.js +1 -0
- package/src/app/main.js +50 -6
- package/src/app/md-render.js +95 -0
- package/src/app/scaffold.js +14 -6
- package/src/app/server.js +102 -0
- package/src/app/templates/package.json.tmpl +1 -1
- package/src/app/tui-run.js +198 -0
- package/src/app/tui.js +398 -0
- package/src/kernel/bg.js +70 -0
- package/src/kernel/goal-loop.js +34 -0
- package/src/kernel/index.js +51 -2
- package/src/kernel/todo.js +31 -0
- package/src/packs/coding/index.js +58 -18
- package/src/packs/data-query/index.js +35 -29
- package/src/packs/deep-research/index.js +41 -0
- package/src/packs/devops/index.js +30 -0
- package/src/packs/general/index.js +100 -0
- package/src/packs/shared/code-nav.js +0 -0
- package/src/packs/shared/fs-tools.js +82 -0
- package/src/packs/shared/web-tools.js +57 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Server app(PoC)— 把 kernel 包成 HTTP 服務(零依賴 node:http)。
|
|
2
|
+
// 證明 kernel 能脫離 CLI 跑成服務:bearer token 認證、per-session 隔離工作目錄、沙箱、結構化日誌、
|
|
3
|
+
// JSON 或 SSE 串流。這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { mkdirSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createKernel } from '../kernel/index.js';
|
|
9
|
+
import { loadModel } from './providers.js';
|
|
10
|
+
import { createCodingPack } from '../packs/coding/index.js';
|
|
11
|
+
import { createDataQueryPack } from '../packs/data-query/index.js';
|
|
12
|
+
import { createNotesPack } from '../packs/notes/index.js';
|
|
13
|
+
import { createGeneralPack } from '../packs/general/index.js';
|
|
14
|
+
import { createDeepResearchPack } from '../packs/deep-research/index.js';
|
|
15
|
+
import { createDevopsPack } from '../packs/devops/index.js';
|
|
16
|
+
|
|
17
|
+
const PACKS = {
|
|
18
|
+
coding: createCodingPack, 'data-query': createDataQueryPack, notes: createNotesPack,
|
|
19
|
+
general: createGeneralPack, 'deep-research': createDeepResearchPack, devops: createDevopsPack,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
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
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {Object} o
|
|
27
|
+
* @param {object} o.model
|
|
28
|
+
* @param {Function} o.getApiKey
|
|
29
|
+
* @param {string} [o.token] bearer token(未設=不驗證,僅 PoC)
|
|
30
|
+
* @param {string} [o.baseDir] 每個 session 的隔離工作目錄根
|
|
31
|
+
* @param {boolean} [o.sandbox] 是否沙箱(預設 true:服務端跑 agent 應隔離)
|
|
32
|
+
* @returns {import('node:http').Server}
|
|
33
|
+
*/
|
|
34
|
+
export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true } = {}) {
|
|
35
|
+
const sessions = new Map(); // sessionId -> { pack, history }
|
|
36
|
+
mkdirSync(baseDir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); };
|
|
39
|
+
const authed = (req) => !token || (req.headers.authorization === `Bearer ${token}`);
|
|
40
|
+
const log = (o) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...o }));
|
|
41
|
+
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({}); } }); });
|
|
42
|
+
|
|
43
|
+
return createServer(async (req, res) => {
|
|
44
|
+
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 });
|
|
46
|
+
if (!authed(req)) return json(res, 401, { error: 'unauthorized(帶 Authorization: Bearer <token>)' });
|
|
47
|
+
|
|
48
|
+
if (req.method === 'POST' && (url.pathname === '/v1/run' || url.pathname === '/v1/stream')) {
|
|
49
|
+
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' });
|
|
60
|
+
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
|
+
const t0 = Date.now();
|
|
69
|
+
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);
|
|
79
|
+
} 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 });
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
json(res, 404, { error: 'not found' });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function startServer() {
|
|
90
|
+
const port = Number(process.env.PORT || 8787);
|
|
91
|
+
const token = process.env.XITTO_SERVER_TOKEN || 'dev-token';
|
|
92
|
+
const sandbox = process.env.XITTO_SERVER_SANDBOX !== 'off';
|
|
93
|
+
const { model, getApiKey } = loadModel(process.env.XITTO_MODEL);
|
|
94
|
+
const server = createServerApp({ model, getApiKey, token, sandbox });
|
|
95
|
+
server.listen(port, () => {
|
|
96
|
+
console.log(`xitto-kernel server · http://localhost:${port} · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'}`);
|
|
97
|
+
console.log(`token: ${token === 'dev-token' ? 'dev-token(請設 XITTO_SERVER_TOKEN)' : '(已設定)'}`);
|
|
98
|
+
});
|
|
99
|
+
return server;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) startServer();
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Ink TUI driver — 把 kernel 接上 tui.js 的 store/handlers/App(對標 Claude Code 的全 TUI 體驗)。
|
|
2
|
+
// 常駐狀態列、串流即時重繪、Esc 中斷、權限 Select、@檔案/!bash/#記憶/斜線指令。
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { createKernel } from '../kernel/index.js';
|
|
8
|
+
import { createStore, mountTui, gutter } from './tui.js';
|
|
9
|
+
import { md } from './md-render.js';
|
|
10
|
+
|
|
11
|
+
const summarize = (args) => { const s = JSON.stringify(args ?? {}); return s.length > 60 ? s.slice(0, 57) + '…' : s; };
|
|
12
|
+
const Y = (s) => `\x1b[33m${s}\x1b[39m`; const G = (s) => `\x1b[90m${s}\x1b[39m`; const R = (s) => `\x1b[31m${s}\x1b[39m`; const C = (s) => `\x1b[36m${s}\x1b[39m`;
|
|
13
|
+
|
|
14
|
+
const SLASH = { '/help': '說明', '/goal': '目標循環', '/sandbox': '沙箱', '/auto': '自動核准', '/plan': '計劃模式', '/undo': '撤銷', '/tools': '工具', '/memory': '記憶', '/sessions': '對話', '/resume': '續接', '/cost': '成本', '/clear': '清除', '/exit': '離開' };
|
|
15
|
+
|
|
16
|
+
export function runTui({ pack, model, getApiKey, sandbox = false, resume = null }) {
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
const store = createStore();
|
|
19
|
+
let history = [];
|
|
20
|
+
let sessionId;
|
|
21
|
+
let currentAgent = null;
|
|
22
|
+
let planMode = false;
|
|
23
|
+
let sandboxOn = !!sandbox;
|
|
24
|
+
let autoApprove = false;
|
|
25
|
+
let pendingSelect = null;
|
|
26
|
+
const sessionTok = { in: 0, out: 0 };
|
|
27
|
+
|
|
28
|
+
const askConfirm = (name, args, danger) => {
|
|
29
|
+
if (autoApprove && !danger) return Promise.resolve('yes');
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const opts = danger ? ['允許一次', '拒絕'] : ['允許', '此工具全部允許', '拒絕'];
|
|
32
|
+
const map = danger ? ['yes', 'no'] : ['yes', 'always', 'no'];
|
|
33
|
+
pendingSelect = { resolve, map };
|
|
34
|
+
store.askSelect((danger ? R(`⛔ 危險:${danger}\n`) : '') + Y(`允許 ${name}`) + G(`(${summarize(args)})`), opts);
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const kernel = createKernel(pack, {
|
|
39
|
+
model, getApiKey,
|
|
40
|
+
sandbox: { enabled: sandboxOn }, getSandbox: () => sandboxOn,
|
|
41
|
+
getPlanMode: () => planMode, confirm: askConfirm,
|
|
42
|
+
});
|
|
43
|
+
sessionId = kernel.session.newId();
|
|
44
|
+
if (resume) {
|
|
45
|
+
const data = resume === true ? (kernel.session.latest() && kernel.session.load(kernel.session.latest().id)) : kernel.session.load(resume);
|
|
46
|
+
if (data?.messages?.length) { history = data.messages; sessionId = data.id; }
|
|
47
|
+
}
|
|
48
|
+
const persist = () => { try { kernel.session.save(sessionId, history); } catch { /* 略 */ } };
|
|
49
|
+
|
|
50
|
+
// ── kernel 事件 → store ──
|
|
51
|
+
const toolBlock = (name, result, isError) => {
|
|
52
|
+
const text = (result?.content || []).map((c) => c.text || '').join(' ').replace(/\s+/g, ' ').trim();
|
|
53
|
+
return Y(`⏺ ${name}`) + '\n' + (isError ? R(' ⎿ ✗ ' + text.slice(0, 200)) : G(' ⎿ ✓ ' + text.slice(0, 120)));
|
|
54
|
+
};
|
|
55
|
+
const onEvent = (ev) => {
|
|
56
|
+
switch (ev.type) {
|
|
57
|
+
case 'message_update': {
|
|
58
|
+
const a = ev.assistantMessageEvent;
|
|
59
|
+
if (a?.type === 'text_delta' && a.delta) store.appendLive(a.delta);
|
|
60
|
+
else if (a?.type === 'thinking_delta' && a.delta) store.appendThinking(a.delta);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case 'message_end': {
|
|
64
|
+
const u = ev.message?.usage;
|
|
65
|
+
if (u) { sessionTok.in += u.input || 0; sessionTok.out += u.output || 0; const used = (u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0); if (used) store.set({ ctx: { used, total: model.contextWindow || 0 } }); }
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case 'tool_execution_start':
|
|
69
|
+
store.finalizeLive();
|
|
70
|
+
if (ev.toolName === 'todo_write' && Array.isArray(ev.args?.todos)) {
|
|
71
|
+
store.pushBlock(C('☑ 待辦') + '\n' + ev.args.todos.map((t) => ' ' + (t.status === 'completed' ? '\x1b[32m☑\x1b[39m ' + G(t.content) : t.status === 'in_progress' ? Y('◐ ') + t.content : G('☐ ' + t.content))).join('\n'));
|
|
72
|
+
} else {
|
|
73
|
+
store.setTool({ name: ev.toolName, summary: summarize(ev.args) });
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
case 'tool_execution_end':
|
|
77
|
+
store.setTool(null);
|
|
78
|
+
if (ev.toolName !== 'todo_write') store.pushBlock(toolBlock(ev.toolName, ev.result, ev.isError));
|
|
79
|
+
break;
|
|
80
|
+
case 'verify_start': store.finalizeLive(); store.pushBlock(G(' 🔎 自動驗收…')); break;
|
|
81
|
+
case 'verify_end': store.pushBlock(ev.ok ? G(' ✓ 驗收通過') : Y(' ✗ 驗收失敗,修正中…')); break;
|
|
82
|
+
case 'compact': store.pushBlock(G(` ⊙ 已壓縮上下文:${ev.tokensBefore}→${ev.tokensAfter} tokens`)); break;
|
|
83
|
+
case 'hook_fail': store.pushBlock(Y(` ✗ hook 失敗 ${ev.command}`)); break;
|
|
84
|
+
case 'agent_end': store.finalizeLive(); break;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ── @檔案展開 ──
|
|
89
|
+
const expandMentions = (text) => text.replace(/(^|\s)@(\S+)/g, (m, sp, p) => {
|
|
90
|
+
const fp = isAbsolute(p) ? p : join(cwd, p);
|
|
91
|
+
if (existsSync(fp)) { try { return `${sp}${p}\n\n<file path="${p}">\n${readFileSync(fp, 'utf8').slice(0, 8000)}\n</file>`; } catch { return m; } }
|
|
92
|
+
return m;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const refreshGit = () => {
|
|
96
|
+
try { const b = execSync('git rev-parse --abbrev-ref HEAD', { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); const dirty = execSync('git status --short', { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); store.set({ gitLabel: b ? `⎇ ${b}${dirty ? ' ✱' : ''}` : '' }); } catch { store.set({ gitLabel: '' }); }
|
|
97
|
+
};
|
|
98
|
+
const setStatus = () => { store.set({ modelLabel: model.id, cwdLabel: cwd.replace(homedir(), '~'), sandboxLabel: sandboxOn ? '🔒 sandbox' : '', permLabel: autoApprove ? '⚡ auto' : '' }); store.setPlan(planMode); refreshGit(); };
|
|
99
|
+
|
|
100
|
+
const cmdHistory = [];
|
|
101
|
+
let ink;
|
|
102
|
+
const doExit = () => { persist(); try { ink?.unmount(); } catch { /* 略 */ } process.exit(0); };
|
|
103
|
+
|
|
104
|
+
// ── 斜線指令 ──
|
|
105
|
+
const slash = (input) => {
|
|
106
|
+
const [cmd, arg] = input.split(/\s+/);
|
|
107
|
+
switch (cmd) {
|
|
108
|
+
case '/exit': case '/quit': doExit(); return true;
|
|
109
|
+
case '/help': store.pushBlock(G(Object.entries(SLASH).map(([k, v]) => ` ${k} ${v}`).join('\n') + '\n @檔案 引用 · !命令 直接跑 · #文字 存記憶')); return true;
|
|
110
|
+
case '/sandbox': sandboxOn = arg ? arg === 'on' : !sandboxOn; setStatus(); store.pushBlock(sandboxOn ? Y('🔒 沙箱開') : G('沙箱關')); return true;
|
|
111
|
+
case '/auto': autoApprove = arg ? arg === 'on' : !autoApprove; setStatus(); store.pushBlock(autoApprove ? Y('⚡ 自動核准開') : G('自動核准關')); return true;
|
|
112
|
+
case '/plan': planMode = arg ? arg === 'on' : !planMode; setStatus(); store.pushBlock(planMode ? C('📋 計劃模式開') : G('計劃模式關')); return true;
|
|
113
|
+
case '/undo': { const r = kernel.undo(); store.pushBlock(r.undone ? G(`↩ 已撤銷 ${r.path}`) : Y(r.reason)); return true; }
|
|
114
|
+
case '/tools': store.pushBlock(G(kernel.registry.names().join(' '))); return true;
|
|
115
|
+
case '/memory': { const m = kernel.memory.list(); store.pushBlock(m.length ? G(m.map((x) => ' • ' + x).join('\n')) : G('(尚無記憶)')); return true; }
|
|
116
|
+
case '/cost': store.pushBlock(G(`本 session 累計:${sessionTok.in + sessionTok.out} tokens(in ${sessionTok.in} / out ${sessionTok.out})`)); return true;
|
|
117
|
+
case '/sessions': { const ss = kernel.session.list(); store.pushBlock(ss.length ? G(ss.map((s) => ` ${s.id} [${s.count} 則]`).join('\n')) : G('(尚無對話)')); return true; }
|
|
118
|
+
case '/resume': { const t = arg || kernel.session.latest()?.id; const d = t ? kernel.session.load(t) : null; if (d?.messages?.length) { history = d.messages; sessionId = d.id; store.pushBlock(G(`(已續接 ${d.id},${d.messages.length} 則)`)); } else store.pushBlock(Y('找不到可續接的 session')); return true; }
|
|
119
|
+
case '/clear': history = []; sessionId = kernel.session.newId(); store.pushBlock(G('(已清除歷史,開新 session)')); return true;
|
|
120
|
+
default: store.pushBlock(R(`未知指令 ${cmd}(/help)`)); return true;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// ── 送出 ──
|
|
125
|
+
async function onSubmit(raw) {
|
|
126
|
+
const input = (raw || '').trim();
|
|
127
|
+
if (!input) return;
|
|
128
|
+
if (store.get().mode === 'busy') { try { currentAgent?.steer({ role: 'user', content: input }); store.pushBlock(G(' ↪ 已插入引導')); } catch { /* 略 */ } return; }
|
|
129
|
+
cmdHistory.push(input);
|
|
130
|
+
store.pushBlock('\n' + input.split('\n').map((l) => `\x1b[34m▌ \x1b[1m${l}\x1b[22m\x1b[39m`).join('\n'));
|
|
131
|
+
|
|
132
|
+
if (input.startsWith('/goal ') || input === '/goal') { return runGoal(input.slice(5).trim()); }
|
|
133
|
+
if (input.startsWith('/')) { slash(input); return; }
|
|
134
|
+
if (input.startsWith('!')) { const r = (() => { try { return execSync(input.slice(1), { cwd, encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return (e.stdout || '') + (e.stderr || '') || e.message; } })(); store.pushBlock(G('$ ' + input.slice(1)) + '\n' + (r.trim() || '(no output)')); return; }
|
|
135
|
+
if (input.startsWith('#')) { const r = kernel.memory.save(input.slice(1).trim()); store.pushBlock(r.saved ? G('✎ 已記住:' + r.saved) : G('(記憶已存在或空)')); return; }
|
|
136
|
+
|
|
137
|
+
store.setMode('busy'); store.set({ busyAt: Date.now() });
|
|
138
|
+
try {
|
|
139
|
+
const text = expandMentions(planMode ? `[計劃模式:只規劃、列步驟與會改動的檔案,不要實際寫檔或執行命令]\n\n${input}` : input);
|
|
140
|
+
const r = await kernel.runTurn(text, { history, onEvent, onAgent: (a) => { currentAgent = a; } });
|
|
141
|
+
store.finalizeLive(); history = r.messages; persist();
|
|
142
|
+
} catch (e) { store.finalizeLive(); store.pushBlock(R('錯誤:' + e.message)); }
|
|
143
|
+
finally { currentAgent = null; store.setTool(null); store.set({ busyAt: null }); store.setMode('idle'); refreshGit(); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function runGoal(goal) {
|
|
147
|
+
if (!goal) { store.pushBlock(G('用法 /goal <目標>')); return; }
|
|
148
|
+
store.pushBlock(C('🎯 目標:') + goal);
|
|
149
|
+
store.setMode('busy'); store.set({ busyAt: Date.now() });
|
|
150
|
+
try {
|
|
151
|
+
const r = await kernel.runGoal(goal, {
|
|
152
|
+
history,
|
|
153
|
+
onRound: ({ round, maxRounds }) => { store.finalizeLive(); store.pushBlock(Y(`🔁 第 ${round}/${maxRounds} 輪`)); },
|
|
154
|
+
onCheck: ({ done, remaining }) => { store.finalizeLive(); store.pushBlock(done ? G(' ✓ 驗收:已達成') : G(' ↻ ' + remaining)); },
|
|
155
|
+
onEvent, onAgent: (a) => { currentAgent = a; },
|
|
156
|
+
});
|
|
157
|
+
store.finalizeLive(); history = r.history; persist();
|
|
158
|
+
store.pushBlock(r.done ? G(`✅ 目標達成(${r.rounds} 輪)`) : Y(`⚠ 未達成(${r.rounds} 輪)`));
|
|
159
|
+
} catch (e) { store.finalizeLive(); store.pushBlock(R('錯誤:' + e.message)); }
|
|
160
|
+
finally { currentAgent = null; store.setTool(null); store.set({ busyAt: null }); store.setMode('idle'); refreshGit(); }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 補全(斜線 + @檔案)──
|
|
164
|
+
const complete = (text) => {
|
|
165
|
+
const sm = text.match(/^\/(\S*)$/);
|
|
166
|
+
if (sm) { const items = Object.keys(SLASH).filter((c) => c.startsWith('/' + sm[1])).map((c) => ({ value: c, desc: SLASH[c] })); return items.length ? { start: 0, items } : null; }
|
|
167
|
+
const am = text.match(/(?:^|\s)@(\S*)$/);
|
|
168
|
+
if (am) {
|
|
169
|
+
const frag = am[1]; const at = text.length - frag.length - 1; const slashI = frag.lastIndexOf('/');
|
|
170
|
+
const dir = slashI >= 0 ? frag.slice(0, slashI + 1) : ''; const base = slashI >= 0 ? frag.slice(slashI + 1) : frag;
|
|
171
|
+
let entries; try { entries = readdirSync(join(cwd, dir), { withFileTypes: true }); } catch { return null; }
|
|
172
|
+
const items = entries.filter((e) => e.name.startsWith(base) && !e.name.startsWith('.')).slice(0, 8).map((e) => '@' + dir + e.name + (e.isDirectory() ? '/' : ''));
|
|
173
|
+
return items.length ? { start: at, items } : null;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const abort = () => { try { currentAgent?.abort(); } catch { /* 略 */ } if (pendingSelect) { const p = pendingSelect; pendingSelect = null; store.clearSelect(); p.resolve('no'); } store.finalizeLive(); store.setTool(null); store.set({ busyAt: null }); store.setMode('idle'); };
|
|
179
|
+
let lastCtrlC = 0;
|
|
180
|
+
const handlers = {
|
|
181
|
+
onSubmit,
|
|
182
|
+
onCtrlC: () => { if (store.get().mode === 'busy') { abort(); store.pushBlock(Y('⏹ 已中斷')); return; } const now = Date.now(); if (now - lastCtrlC < 2000) doExit(); else { lastCtrlC = now; store.pushBlock(G('再按一次 Ctrl+C 離開')); } },
|
|
183
|
+
onEscape: () => { if (store.get().mode === 'busy') { abort(); store.pushBlock(Y('⏹ 已中斷')); } },
|
|
184
|
+
getHistory: () => cmdHistory,
|
|
185
|
+
complete,
|
|
186
|
+
onSelectChoice: (idx) => { const p = pendingSelect; pendingSelect = null; store.clearSelect(); if (p) p.resolve(p.map[idx] ?? 'no'); },
|
|
187
|
+
onSelectCancel: () => { const p = pendingSelect; pendingSelect = null; store.clearSelect(); if (p) p.resolve('no'); },
|
|
188
|
+
onSelectAbort: () => { abort(); },
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// 橫幅 + 狀態列
|
|
192
|
+
store.pushBlock('\n' + C('✻ ') + '\x1b[1mxitto-kernel\x1b[22m' + G(` · ${pack.name} pack · ${model.id}`) + '\n' + G(' Esc 中斷 · /help · @檔案 · !命令 · #記憶 · Tab 補全'));
|
|
193
|
+
setStatus();
|
|
194
|
+
store.setMode('idle'); store.setPlaceholder('輸入訊息…');
|
|
195
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
196
|
+
ink = mountTui({ store, handlers });
|
|
197
|
+
return ink;
|
|
198
|
+
}
|