@dotdrelle/wiki-manager 0.6.17

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.
@@ -0,0 +1,239 @@
1
+ function trimTrailingSlash(value) {
2
+ return value.replace(/\/+$/, '');
3
+ }
4
+
5
+ function createStreamYieldController() {
6
+ let eventsSinceYield = 0;
7
+ let lastYieldAt = Date.now();
8
+ return async function yieldIfNeeded(force = false) {
9
+ eventsSinceYield += 1;
10
+ const now = Date.now();
11
+ if (!force && eventsSinceYield < 4 && now - lastYieldAt < 12) return;
12
+ eventsSinceYield = 0;
13
+ lastYieldAt = now;
14
+ await new Promise((resolve) => setTimeout(resolve, 0));
15
+ };
16
+ }
17
+
18
+ export function createLlmClientFromWikiConfig(config) {
19
+ const llmConfig = config?.llm;
20
+ const apiKey = llmConfig?.apiKey;
21
+ const model = llmConfig?.model;
22
+ const baseUrl = llmConfig?.baseUrl ? trimTrailingSlash(llmConfig.baseUrl) : undefined;
23
+
24
+ if (!apiKey || !model || !baseUrl) {
25
+ return null;
26
+ }
27
+
28
+ return {
29
+ async complete({ system, input }) {
30
+ const response = await fetch(`${baseUrl}/chat/completions`, {
31
+ method: 'POST',
32
+ headers: {
33
+ Authorization: `Bearer ${apiKey}`,
34
+ 'Content-Type': 'application/json',
35
+ },
36
+ body: JSON.stringify({
37
+ model,
38
+ messages: [
39
+ { role: 'system', content: system },
40
+ { role: 'user', content: input },
41
+ ],
42
+ temperature: typeof llmConfig.temperature === 'number' ? llmConfig.temperature : 0.2,
43
+ }),
44
+ });
45
+
46
+ if (!response.ok) {
47
+ const body = await response.text();
48
+ throw new Error(`HTTP ${response.status} ${body.slice(0, 240)}`);
49
+ }
50
+
51
+ const data = await response.json();
52
+ const content = data?.choices?.[0]?.message?.content;
53
+ if (!content || typeof content !== 'string') {
54
+ throw new Error('reponse LLM sans contenu texte');
55
+ }
56
+ return content;
57
+ },
58
+ async completeWithTools({ system, tools = [], messages = [], signal }) {
59
+ const allMessages = [
60
+ { role: 'system', content: system },
61
+ ...messages,
62
+ ];
63
+ const body = {
64
+ model,
65
+ messages: allMessages,
66
+ temperature: typeof llmConfig.temperature === 'number' ? llmConfig.temperature : 0.2,
67
+ };
68
+ if (tools.length > 0) {
69
+ body.tools = tools;
70
+ body.tool_choice = 'auto';
71
+ }
72
+ const response = await fetch(`${baseUrl}/chat/completions`, {
73
+ method: 'POST',
74
+ signal,
75
+ headers: {
76
+ Authorization: `Bearer ${apiKey}`,
77
+ 'Content-Type': 'application/json',
78
+ },
79
+ body: JSON.stringify(body),
80
+ });
81
+ if (!response.ok) {
82
+ const text = await response.text();
83
+ throw new Error(`HTTP ${response.status} ${text.slice(0, 240)}`);
84
+ }
85
+ const data = await response.json();
86
+ const msg = data?.choices?.[0]?.message;
87
+ return {
88
+ content: msg?.content ?? null,
89
+ tool_calls: msg?.tool_calls?.length > 0 ? msg.tool_calls : null,
90
+ message: { role: 'assistant', content: msg?.content ?? null, tool_calls: msg?.tool_calls },
91
+ };
92
+ },
93
+ async streamWithTools({ system, tools = [], messages = [], onTextDelta, signal }) {
94
+ const allMessages = [
95
+ { role: 'system', content: system },
96
+ ...messages,
97
+ ];
98
+ const body = {
99
+ model,
100
+ messages: allMessages,
101
+ temperature: typeof llmConfig.temperature === 'number' ? llmConfig.temperature : 0.2,
102
+ stream: true,
103
+ };
104
+ if (tools.length > 0) {
105
+ body.tools = tools;
106
+ body.tool_choice = 'auto';
107
+ }
108
+ const response = await fetch(`${baseUrl}/chat/completions`, {
109
+ method: 'POST',
110
+ signal,
111
+ headers: {
112
+ Authorization: `Bearer ${apiKey}`,
113
+ 'Content-Type': 'application/json',
114
+ },
115
+ body: JSON.stringify(body),
116
+ });
117
+ if (!response.ok) {
118
+ const text = await response.text();
119
+ throw new Error(`HTTP ${response.status} ${text.slice(0, 240)}`);
120
+ }
121
+ if (!response.body) {
122
+ const result = await this.completeWithTools({ system, tools, messages, signal });
123
+ if (result.content) onTextDelta?.(result.content);
124
+ return result;
125
+ }
126
+ const toolCallsMap = {};
127
+ let textContent = '';
128
+ const decoder = new TextDecoder();
129
+ let buffer = '';
130
+ const yieldForRender = createStreamYieldController();
131
+ for await (const chunk of response.body) {
132
+ buffer += decoder.decode(chunk, { stream: true });
133
+ const lines = buffer.split('\n');
134
+ buffer = lines.pop() ?? '';
135
+ let chunkHadContent = false;
136
+ for (const line of lines) {
137
+ const trimmed = line.trim();
138
+ if (!trimmed.startsWith('data:')) continue;
139
+ const data = trimmed.slice('data:'.length).trim();
140
+ if (!data || data === '[DONE]') continue;
141
+ let parsed;
142
+ try { parsed = JSON.parse(data); } catch { continue; }
143
+ const delta = parsed?.choices?.[0]?.delta;
144
+ if (!delta) continue;
145
+ if (typeof delta.content === 'string' && delta.content) {
146
+ textContent += delta.content;
147
+ onTextDelta?.(delta.content);
148
+ chunkHadContent = true;
149
+ await yieldForRender();
150
+ }
151
+ if (delta.tool_calls) {
152
+ for (const tc of delta.tool_calls) {
153
+ const idx = tc.index ?? 0;
154
+ if (!toolCallsMap[idx]) {
155
+ toolCallsMap[idx] = { id: '', type: 'function', function: { name: '', arguments: '' } };
156
+ }
157
+ if (tc.id) toolCallsMap[idx].id = tc.id;
158
+ if (tc.type) toolCallsMap[idx].type = tc.type;
159
+ if (tc.function?.name) toolCallsMap[idx].function.name += tc.function.name;
160
+ if (tc.function?.arguments) toolCallsMap[idx].function.arguments += tc.function.arguments;
161
+ }
162
+ }
163
+ }
164
+ // Yield to the macro-task queue after each chunk that produced text so
165
+ // OpenTUI's render loop (setInterval at 30 fps) can fire between bursts.
166
+ if (chunkHadContent) await yieldForRender(true);
167
+ }
168
+ const toolCalls = Object.keys(toolCallsMap)
169
+ .sort((a, b) => Number(a) - Number(b))
170
+ .map((idx) => toolCallsMap[idx]);
171
+ const message = {
172
+ role: 'assistant',
173
+ content: textContent || null,
174
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
175
+ };
176
+ return {
177
+ content: textContent || null,
178
+ tool_calls: toolCalls.length > 0 ? toolCalls : null,
179
+ message,
180
+ };
181
+ },
182
+ async *stream({ system, messages = [], signal }) {
183
+ const allMessages = [
184
+ { role: 'system', content: system },
185
+ ...messages,
186
+ ];
187
+ const response = await fetch(`${baseUrl}/chat/completions`, {
188
+ method: 'POST',
189
+ signal,
190
+ headers: {
191
+ Authorization: `Bearer ${apiKey}`,
192
+ 'Content-Type': 'application/json',
193
+ },
194
+ body: JSON.stringify({
195
+ model,
196
+ messages: allMessages,
197
+ temperature: typeof llmConfig.temperature === 'number' ? llmConfig.temperature : 0.2,
198
+ stream: true,
199
+ }),
200
+ });
201
+
202
+ if (!response.ok) {
203
+ const body = await response.text();
204
+ throw new Error(`HTTP ${response.status} ${body.slice(0, 240)}`);
205
+ }
206
+
207
+ if (!response.body) {
208
+ const fallback = await this.completeWithTools({ system, messages });
209
+ yield fallback.content ?? '';
210
+ return;
211
+ }
212
+
213
+ const decoder = new TextDecoder();
214
+ let buffer = '';
215
+ const yieldForRender = createStreamYieldController();
216
+ for await (const chunk of response.body) {
217
+ buffer += decoder.decode(chunk, { stream: true });
218
+ const lines = buffer.split('\n');
219
+ buffer = lines.pop() ?? '';
220
+ let chunkHadContent = false;
221
+ for (const line of lines) {
222
+ const trimmed = line.trim();
223
+ if (!trimmed.startsWith('data:')) continue;
224
+ const data = trimmed.slice('data:'.length).trim();
225
+ if (!data || data === '[DONE]') continue;
226
+ let parsed;
227
+ try { parsed = JSON.parse(data); } catch { continue; }
228
+ const delta = parsed?.choices?.[0]?.delta?.content;
229
+ if (typeof delta === 'string' && delta) {
230
+ yield delta;
231
+ chunkHadContent = true;
232
+ await yieldForRender();
233
+ }
234
+ }
235
+ if (chunkHadContent) await yieldForRender(true);
236
+ }
237
+ },
238
+ };
239
+ }
@@ -0,0 +1,389 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { loadManagerEnv } from '../core/env.js';
6
+ loadManagerEnv();
7
+ import { createAgentGraph, buildAgentSystemPrompt, buildLimitedAgentResponse } from '../agent/graph.js';
8
+ import { handleSlashCommand, printHelp, printVersion } from '../commands/slash.js';
9
+ import { runShell } from '../shell/repl.js';
10
+ import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
11
+ import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
12
+ import { extractHeadlessPlan, syncActivitiesToPlan, formatPlanStatus, formatCompletedActivities } from '../core/plan.js';
13
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const packageJsonPath = resolve(__dirname, '../../package.json');
17
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
18
+ const SHELL_COMMANDS = ['help', 'version', 'exit', 'workspaces', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'clear', 'chat', 'agent'];
19
+
20
+ function valueAfter(argv, flag) {
21
+ const index = argv.indexOf(flag);
22
+ if (index === -1) return undefined;
23
+ return argv[index + 1];
24
+ }
25
+
26
+ function createSession() {
27
+ return {
28
+ workspace: null,
29
+ workspacePath: null,
30
+ workspaceEnvFile: null,
31
+ wikirc: null,
32
+ wikircConfig: null,
33
+ language: null,
34
+ mcp: null,
35
+ commands: SHELL_COMMANDS,
36
+ chatMode: true,
37
+ llm: null,
38
+ packageJson,
39
+ conversations: { __global__: [] },
40
+ activities: {},
41
+ productionActivity: null,
42
+ headlessPlan: null,
43
+ };
44
+ }
45
+
46
+ function timestampForFile() {
47
+ return new Date().toISOString().replace(/[:.]/g, '-');
48
+ }
49
+
50
+ async function writeHeadlessLog(session, lines, explicitPath) {
51
+ const logPath = explicitPath
52
+ ? resolve(explicitPath)
53
+ : join(session.workspacePath ?? process.cwd(), '.wiki', 'logs', `headless-${timestampForFile()}.log`);
54
+ await mkdir(dirname(logPath), { recursive: true });
55
+ await writeFile(logPath, `${lines.join('\n')}\n`, 'utf8');
56
+ return logPath;
57
+ }
58
+
59
+ function activitySnapshot(session) {
60
+ return new Set(sessionActivities(session).map((a) => a.key));
61
+ }
62
+
63
+ function newNonTerminalActivities(snapshotBefore, session) {
64
+ return sessionActivities(session).filter((a) => !snapshotBefore.has(a.key) && !a.terminal);
65
+ }
66
+
67
+ function terminalFailures(activities) {
68
+ return activities.filter(
69
+ (a) => a.terminal && ['failed', 'error', 'cancelled', 'canceled'].includes(String(a.status).toLowerCase()),
70
+ );
71
+ }
72
+
73
+
74
+ async function runHeadlessActivityLoop(session, log, { wait, timeoutMs }) {
75
+ if (!wait) return { exitCode: 0, completed: [], timedOut: false };
76
+ const deadline = Date.now() + timeoutMs;
77
+ const pollBusy = new Set();
78
+ // Track which keys were non-terminal when we entered, so we can report them on exit.
79
+ const trackedKeys = new Set(sessionActivities(session).filter((a) => !a.terminal).map((a) => a.key));
80
+ log.push(`activity-loop: started, timeout=${Math.round(timeoutMs / 1000)}s`);
81
+ console.log(`[headless] Waiting for active jobs (timeout: ${Math.round(timeoutMs / 1000)}s)…`);
82
+
83
+ while (Date.now() < deadline) {
84
+ const candidates = sessionActivities(session).filter((a) => a.poll && !a.terminal);
85
+ if (candidates.length === 0) {
86
+ const completed = sessionActivities(session).filter((a) => trackedKeys.has(a.key));
87
+ const failures = terminalFailures(completed);
88
+ if (failures.length > 0) {
89
+ for (const a of failures) {
90
+ const line = `activity-loop: ${a.label} → ${a.status}${a.error ? ` — ${a.error}` : ''}`;
91
+ log.push(line);
92
+ console.error(`[headless] ${line}`);
93
+ }
94
+ return { exitCode: 1, completed, timedOut: false };
95
+ }
96
+ if (completed.length > 0) {
97
+ log.push('activity-loop: all activities terminal');
98
+ console.log('[headless] All jobs completed.');
99
+ }
100
+ return { exitCode: 0, completed, timedOut: false };
101
+ }
102
+
103
+ for (const activity of candidates) {
104
+ const key = activity.key ?? `${activity.poll.server}:${activity.id ?? 'activity'}`;
105
+ if (pollBusy.has(key)) continue;
106
+ const endpoint = session.mcp?.[activity.poll.server];
107
+ if (!endpoint || endpoint.status !== 'connected') {
108
+ const line = `activity-loop: MCP server '${activity.poll.server}' not connected — cannot poll ${key}`;
109
+ log.push(line);
110
+ console.error(`[headless] ${line}`);
111
+ const completed = sessionActivities(session).filter((a) => trackedKeys.has(a.key));
112
+ return { exitCode: 1, completed, timedOut: false };
113
+ }
114
+ const intervalMs = activity.poll.intervalMs ?? 2500;
115
+ if (Date.now() - Date.parse(activity.lastPolledAt ?? '0') < intervalMs) continue;
116
+ pollBusy.add(key);
117
+ activity.lastPolledAt = new Date().toISOString();
118
+ try {
119
+ const result = await callMcpTool(session.mcp, activity.poll.server, activity.poll.tool, activity.poll.args ?? {});
120
+ const payload = parseJsonText(formatMcpToolResult(result));
121
+ const polledActivity = extractActivity(payload, { server: activity.poll.server, tool: activity.poll.tool });
122
+ if (polledActivity) {
123
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
124
+ origin: 'poll',
125
+ payload: { activity: polledActivity },
126
+ }));
127
+ } else {
128
+ syncActivitiesToPlan(session.headlessPlan, sessionActivities(session));
129
+ }
130
+ const updated = sessionActivities(session).find((a) => a.key === key);
131
+ if (updated) {
132
+ const line = `activity-loop: ${updated.label} → ${updated.status}${updated.error ? ` — ${updated.error}` : ''}`;
133
+ log.push(line);
134
+ console.log(`[headless] ${line}`);
135
+ }
136
+ } catch (err) {
137
+ log.push(`activity-loop: poll error ${key} — ${err instanceof Error ? err.message : String(err)}`);
138
+ } finally {
139
+ pollBusy.delete(key);
140
+ }
141
+ }
142
+
143
+ await new Promise((resolve) => setTimeout(resolve, 1000));
144
+ }
145
+
146
+ const completed = sessionActivities(session).filter((a) => trackedKeys.has(a.key));
147
+ log.push('activity-loop: timeout');
148
+ console.error('[headless] Timeout waiting for activities to complete.');
149
+ return { exitCode: 1, completed, timedOut: true };
150
+ }
151
+
152
+ async function runHeadlessAgentTurn(agent, session, input, log, messages = []) {
153
+ session.packageJson = packageJson;
154
+ let streamedContent = '';
155
+ session._onStream = (delta) => { streamedContent += delta; };
156
+ session._onStreamReset = () => { streamedContent = ''; };
157
+ let result;
158
+ try {
159
+ result = await agent.invoke({ input, session, messages });
160
+ } finally {
161
+ delete session._onStream;
162
+ delete session._onStreamReset;
163
+ }
164
+ if (result.streamedInline) {
165
+ return streamedContent.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
166
+ }
167
+ if (result.response != null) return result.response;
168
+ if (result.readyToStream && session.llm?.stream) {
169
+ const { system, messages: streamMessages = [] } = result.streamContext ?? {};
170
+ let content = '';
171
+ for await (const delta of session.llm.stream({
172
+ system: system ?? buildAgentSystemPrompt({ input, session }),
173
+ messages: streamMessages,
174
+ })) {
175
+ content += delta;
176
+ }
177
+ return content.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
178
+ }
179
+ return buildLimitedAgentResponse({ input, session });
180
+ }
181
+
182
+ async function runHeadlessAgenticLoop(agent, session, initialInput, log, { timeoutMs, maxTurns }) {
183
+ const conversationHistory = [];
184
+ let currentInput = initialInput;
185
+
186
+ for (let turn = 1; turn <= maxTurns; turn++) {
187
+ log.push(`agentic-loop: turn ${turn}/${maxTurns}`);
188
+ console.log(`[headless] Agent turn ${turn}/${maxTurns}…`);
189
+
190
+ const snapshot = activitySnapshot(session);
191
+
192
+ const response = await runHeadlessAgentTurn(agent, session, currentInput, log, conversationHistory);
193
+ log.push(`agentic-loop: turn ${turn} response:`);
194
+ log.push(response);
195
+ console.log(response);
196
+
197
+ conversationHistory.push(
198
+ { role: 'user', content: currentInput },
199
+ { role: 'assistant', content: response },
200
+ );
201
+
202
+ // session.headlessPlan is set authoritatively by wiki__plan_set tool call.
203
+ // Fall back to text extraction only if the agent didn't call the tool.
204
+ if (turn === 1 && session.headlessPlan === null) {
205
+ const extractedPlan = extractHeadlessPlan(response);
206
+ if (extractedPlan) {
207
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
208
+ origin: 'llm',
209
+ payload: { steps: extractedPlan },
210
+ }));
211
+ log.push(`agentic-loop: plan extracted from text (${session.headlessPlan.length} steps, fallback)`);
212
+ }
213
+ } else if (turn === 1 && session.headlessPlan) {
214
+ log.push(`agentic-loop: plan set via tool (${session.headlessPlan.length} steps)`);
215
+ }
216
+
217
+ const newPending = newNonTerminalActivities(snapshot, session);
218
+ if (newPending.length === 0) {
219
+ const pendingSteps = (session.headlessPlan ?? []).filter((s) => s.status === 'pending');
220
+ if (pendingSteps.length === 0) {
221
+ log.push('agentic-loop: no new non-terminal activities — plan complete');
222
+ console.log('[headless] Plan complete.');
223
+ return { exitCode: 0 };
224
+ }
225
+ log.push(`agentic-loop: no new async activity, ${pendingSteps.length} pending step(s) remain`);
226
+ if (session.headlessPlan) log.push(`agentic-loop: plan status:\n${formatPlanStatus(session.headlessPlan)}`);
227
+ currentInput = [
228
+ 'Original task:',
229
+ initialInput,
230
+ '',
231
+ `Plan status:\n${formatPlanStatus(session.headlessPlan)}`,
232
+ '',
233
+ 'No new background activity was started in the previous turn.',
234
+ 'Continue the original plan. Start the next pending step only.',
235
+ 'If required information is missing and cannot be inferred, stop with a clear blocker.',
236
+ ].join('\n');
237
+ continue;
238
+ }
239
+
240
+ log.push(`agentic-loop: ${newPending.length} new job(s) started, waiting…`);
241
+ const { exitCode, completed, timedOut } = await runHeadlessActivityLoop(session, log, { wait: true, timeoutMs });
242
+
243
+ if (timedOut || exitCode !== 0) return { exitCode };
244
+
245
+ const summary = formatCompletedActivities(completed);
246
+ log.push(`agentic-loop: completed activities:\n${summary}`);
247
+ if (session.headlessPlan) log.push(`agentic-loop: plan status:\n${formatPlanStatus(session.headlessPlan)}`);
248
+ currentInput = [
249
+ 'Original task:',
250
+ initialInput,
251
+ '',
252
+ session.headlessPlan ? `Plan status:\n${formatPlanStatus(session.headlessPlan)}\n` : null,
253
+ 'Completed activities:',
254
+ summary,
255
+ '',
256
+ 'Continue the original plan. Start the next required step only.',
257
+ 'If all steps are complete, provide the final summary.',
258
+ ].filter(Boolean).join('\n');
259
+ }
260
+
261
+ log.push(`agentic-loop: max turns (${maxTurns}) reached without completing`);
262
+ console.error(`[headless] Max agent turns (${maxTurns}) reached.`);
263
+ return { exitCode: 1 };
264
+ }
265
+
266
+ async function runHeadless(argv, agent) {
267
+ const workspaceName = valueAfter(argv, '--workspace');
268
+ const skillName = valueAfter(argv, '--skill');
269
+ const prompt = valueAfter(argv, '--prompt');
270
+ const logFile = valueAfter(argv, '--log-file');
271
+ const timeoutArg = valueAfter(argv, '--timeout');
272
+ const maxTurnsArg = valueAfter(argv, '--max-turns');
273
+ const timeoutMs = (Number.isFinite(Number(timeoutArg)) ? Math.max(1, Number(timeoutArg)) : 3600) * 1000;
274
+ const maxTurns = Number.isFinite(Number(maxTurnsArg)) ? Math.max(1, Number(maxTurnsArg)) : 20;
275
+ // --skill uses the agentic loop (multi-turn); --prompt uses a single turn unless --wait is set.
276
+ const useAgenticLoop = Boolean(skillName) && !argv.includes('--no-wait');
277
+ const wait = !useAgenticLoop && (argv.includes('--wait'));
278
+ const log = [`wiki-manager ${packageJson.version} headless`, `startedAt=${new Date().toISOString()}`];
279
+
280
+ if (!workspaceName) throw new Error('Usage: wiki-manager --headless --workspace <name> (--skill <name>|--prompt <text>)');
281
+ if (!skillName && !prompt) throw new Error('Usage: wiki-manager --headless --workspace <name> (--skill <name>|--prompt <text>)');
282
+
283
+ const session = createSession();
284
+ session.headless = true;
285
+ session.chatMode = false;
286
+ const step = (line) => {
287
+ log.push(line);
288
+ console.log(line);
289
+ };
290
+
291
+ try {
292
+ const useResult = await handleSlashCommand(`/use ${workspaceName}`, { packageJson, session, onStep: step });
293
+ if (useResult.output) log.push(useResult.output);
294
+ if (!session.workspacePath) throw new Error(useResult.output || `Workspace not loaded: ${workspaceName}`);
295
+ if (!session.llm) throw new Error(`Workspace ${workspaceName} has no usable LLM config.`);
296
+
297
+ let input = prompt;
298
+ if (skillName) {
299
+ const skillResult = await handleSlashCommand(`/skills run ${skillName}`, { packageJson, session, onStep: step });
300
+ if (skillResult.output) log.push(skillResult.output);
301
+ if (String(skillResult.output ?? '').startsWith('Skill not found')) throw new Error(`Skill not found: ${skillName}`);
302
+ input = skillResult.agentTrigger
303
+ ? [
304
+ skillResult.agentTrigger,
305
+ prompt ? `Additional instruction: ${prompt}` : null,
306
+ ].filter(Boolean).join('\n\n')
307
+ : [
308
+ `Run the ${skillName} skill for workspace ${workspaceName} in headless mode.`,
309
+ prompt ? `Additional instruction: ${prompt}` : null,
310
+ '',
311
+ skillResult.output,
312
+ ].filter(Boolean).join('\n');
313
+ }
314
+
315
+ log.push(`input=${input}`);
316
+ dispatchAgentEvent(session, createAgentEvent('run_started', {
317
+ origin: 'user',
318
+ payload: { input },
319
+ }));
320
+ dispatchAgentEvent(session, createAgentEvent('user_message', {
321
+ origin: 'user',
322
+ payload: { content: input },
323
+ }));
324
+ let exitCode = 0;
325
+ if (useAgenticLoop) {
326
+ ({ exitCode } = await runHeadlessAgenticLoop(agent, session, input, log, { timeoutMs, maxTurns }));
327
+ } else {
328
+ const response = await runHeadlessAgentTurn(agent, session, input, log);
329
+ log.push('response:');
330
+ log.push(response);
331
+ console.log(response);
332
+ ({ exitCode } = await runHeadlessActivityLoop(session, log, { wait, timeoutMs }));
333
+ }
334
+ const saved = await writeHeadlessLog(session, log, logFile);
335
+ console.log(`Headless log: ${saved}`);
336
+ if (exitCode !== 0) process.exitCode = exitCode;
337
+ } catch (err) {
338
+ log.push(`error=${err instanceof Error ? err.message : String(err)}`);
339
+ if (session.workspacePath || logFile) {
340
+ const saved = await writeHeadlessLog(session, log, logFile);
341
+ console.error(`Headless log: ${saved}`);
342
+ }
343
+ throw err;
344
+ }
345
+ }
346
+
347
+ export async function runCli(argv) {
348
+ if (argv.includes('--version') || argv.includes('-v')) {
349
+ printVersion(packageJson);
350
+ return;
351
+ }
352
+
353
+ if (argv.includes('--help') || argv.includes('-h')) {
354
+ printHelp(packageJson);
355
+ return;
356
+ }
357
+
358
+ const agent = createAgentGraph();
359
+ if (argv.includes('--headless')) {
360
+ try {
361
+ await runHeadless(argv, agent);
362
+ } catch (err) {
363
+ console.error(err instanceof Error ? err.message : String(err));
364
+ process.exitCode = 1;
365
+ }
366
+ return;
367
+ }
368
+
369
+ const once = valueAfter(argv, '--once');
370
+ if (once) {
371
+ const result = await agent.invoke({
372
+ input: once,
373
+ session: createSession(),
374
+ });
375
+ console.log(result.response);
376
+ return;
377
+ }
378
+
379
+ if (process.stdin.isTTY && process.stdout.isTTY) {
380
+ if (!process.versions.bun) {
381
+ throw new Error('Interactive TUI requires Bun. Run: bun ./bin/wiki-manager.js');
382
+ }
383
+ const { runOpenTuiShell } = await import('../shell/tui.tsx');
384
+ await runOpenTuiShell({ agent, packageJson });
385
+ return;
386
+ }
387
+
388
+ await runShell({ agent, packageJson });
389
+ }