@aitherium/shell-cli 1.1.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.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * GargBot CLI commands — chat, ingest, search, status, and Obsidian sync.
3
+ *
4
+ * Usage:
5
+ * aither gargbot chat "What projects did we do in Texas?"
6
+ * aither gargbot ingest ./resumes/john_doe.pdf
7
+ * aither gargbot search "bridge engineers with PE"
8
+ * aither gargbot status
9
+ * aither gargbot sync-obsidian /path/to/vault
10
+ */
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import * as os from 'os';
14
+ function getGargbotUrl() {
15
+ // 1. Environment variable
16
+ if (process.env.GARGBOT_URL)
17
+ return process.env.GARGBOT_URL;
18
+ // 2. Config file ~/.aither/gargbot.json
19
+ const configPath = path.join(os.homedir(), '.aither', 'gargbot.json');
20
+ try {
21
+ if (fs.existsSync(configPath)) {
22
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
23
+ if (cfg.url)
24
+ return cfg.url;
25
+ }
26
+ }
27
+ catch { /* fallthrough */ }
28
+ // 3. Default
29
+ return 'http://localhost:8900';
30
+ }
31
+ async function gargGet(endpoint) {
32
+ const url = `${getGargbotUrl()}${endpoint}`;
33
+ const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
34
+ if (!res.ok)
35
+ throw new Error(`${res.status} ${res.statusText}`);
36
+ return res.json();
37
+ }
38
+ async function gargPost(endpoint, body) {
39
+ const url = `${getGargbotUrl()}${endpoint}`;
40
+ const res = await fetch(url, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
43
+ body: JSON.stringify(body),
44
+ });
45
+ if (!res.ok)
46
+ throw new Error(`${res.status} ${res.statusText}`);
47
+ return res.json();
48
+ }
49
+ async function gargUpload(endpoint, filePath, fields = {}) {
50
+ const url = `${getGargbotUrl()}${endpoint}`;
51
+ const fileBuffer = fs.readFileSync(filePath);
52
+ const fileName = path.basename(filePath);
53
+ // Build multipart form data manually for Node fetch
54
+ const boundary = '----AitherShellBoundary' + Date.now().toString(36);
55
+ let body = '';
56
+ for (const [key, val] of Object.entries(fields)) {
57
+ body += `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${val}\r\n`;
58
+ }
59
+ body += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
60
+ const ending = `\r\n--${boundary}--\r\n`;
61
+ const bodyBuffer = Buffer.concat([
62
+ Buffer.from(body, 'utf-8'),
63
+ fileBuffer,
64
+ Buffer.from(ending, 'utf-8'),
65
+ ]);
66
+ const res = await fetch(url, {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
69
+ body: bodyBuffer,
70
+ });
71
+ if (!res.ok)
72
+ throw new Error(`${res.status} ${res.statusText}`);
73
+ return res.json();
74
+ }
75
+ // ── Subcommand handlers ──────────────────────────────────────────────────
76
+ async function handleChat(args) {
77
+ const message = args.join(' ');
78
+ if (!message) {
79
+ console.log('\x1b[33mUsage:\x1b[0m aither gargbot chat <message>');
80
+ return;
81
+ }
82
+ console.log('\x1b[2mThinking...\x1b[0m');
83
+ try {
84
+ const data = await gargPost('/api/chat', { message });
85
+ console.log(`\n\x1b[36mGargBot:\x1b[0m ${data.response}`);
86
+ if (data.sources?.length > 0) {
87
+ console.log(`\n\x1b[2mSources: ${data.sources.map((s) => s.filename).join(', ')}\x1b[0m`);
88
+ }
89
+ }
90
+ catch (e) {
91
+ console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
92
+ }
93
+ }
94
+ async function handleIngest(args) {
95
+ const target = args[0];
96
+ // If no args and stdin is piped, read from stdin
97
+ if (!target && !process.stdin.isTTY) {
98
+ const chunks = [];
99
+ for await (const chunk of process.stdin) {
100
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
101
+ }
102
+ const text = Buffer.concat(chunks).toString('utf-8').trim();
103
+ if (!text) {
104
+ console.error('\x1b[31mNo input received from stdin\x1b[0m');
105
+ return;
106
+ }
107
+ console.log('\x1b[2mIngesting from stdin...\x1b[0m');
108
+ try {
109
+ const data = await gargPost('/api/documents/ingest', {
110
+ text,
111
+ filename: `stdin-${Date.now()}`,
112
+ doc_type: 'other',
113
+ });
114
+ console.log(`\x1b[32m✓\x1b[0m Ingested stdin (${data.chunks_created || '?'} chunks)`);
115
+ }
116
+ catch (e) {
117
+ console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
118
+ }
119
+ return;
120
+ }
121
+ if (!target) {
122
+ console.log('\x1b[33mUsage:\x1b[0m aither gargbot ingest <file-or-url>');
123
+ console.log(' echo "text" | aither gargbot ingest');
124
+ return;
125
+ }
126
+ // URL detection
127
+ if (target.startsWith('http://') || target.startsWith('https://')) {
128
+ console.log(`\x1b[2mIngesting URL: ${target}...\x1b[0m`);
129
+ try {
130
+ const data = await gargPost('/api/documents/ingest-url', { url: target, auto_extract: true });
131
+ console.log(`\x1b[32m✓\x1b[0m Ingested: ${data.title || target} (${data.chunk_count || '?'} chunks)`);
132
+ }
133
+ catch (e) {
134
+ console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
135
+ }
136
+ return;
137
+ }
138
+ // File path
139
+ const resolved = path.resolve(target);
140
+ if (!fs.existsSync(resolved)) {
141
+ console.error(`\x1b[31mFile not found:\x1b[0m ${resolved}`);
142
+ return;
143
+ }
144
+ console.log(`\x1b[2mIngesting ${path.basename(resolved)}...\x1b[0m`);
145
+ try {
146
+ const data = await gargUpload('/api/documents/upload', resolved, { auto_extract: 'true' });
147
+ console.log(`\x1b[32m✓\x1b[0m Ingested: ${data.filename || path.basename(resolved)} (${data.chunk_count || data.chunks_created || '?'} chunks)`);
148
+ }
149
+ catch (e) {
150
+ console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
151
+ }
152
+ }
153
+ async function handleSearch(args) {
154
+ const query = args.join(' ');
155
+ if (!query) {
156
+ console.log('\x1b[33mUsage:\x1b[0m aither gargbot search <query>');
157
+ return;
158
+ }
159
+ try {
160
+ const data = await gargPost('/api/chat', { message: `Search: ${query}` });
161
+ console.log(`\n\x1b[36mResults:\x1b[0m ${data.response}`);
162
+ }
163
+ catch (e) {
164
+ console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
165
+ }
166
+ }
167
+ async function handleStatus(_args) {
168
+ try {
169
+ const health = await gargGet('/api/health');
170
+ const stats = await gargGet('/api/stats');
171
+ console.log(`\x1b[36mGargBot Status\x1b[0m`);
172
+ console.log(` Service: ${health.service}`);
173
+ console.log(` Provider: ${health.llm_provider}`);
174
+ console.log(` Mode: ${health.deployment_mode}`);
175
+ console.log(` Documents: ${stats.documents}`);
176
+ console.log(` Staff: ${stats.staff}`);
177
+ console.log(` Projects: ${stats.projects}`);
178
+ console.log(` Feedback: ${stats.feedback_entries}`);
179
+ }
180
+ catch (e) {
181
+ console.error(`\x1b[31mCannot reach GargBot:\x1b[0m ${e.message}`);
182
+ console.log(` URL: ${getGargbotUrl()}`);
183
+ }
184
+ }
185
+ async function handleSyncObsidian(args) {
186
+ const vaultPath = args[0];
187
+ if (!vaultPath) {
188
+ console.log('\x1b[33mUsage:\x1b[0m aither gargbot sync-obsidian <vault-path>');
189
+ return;
190
+ }
191
+ console.log(`\x1b[2mSyncing vault: ${vaultPath}...\x1b[0m`);
192
+ try {
193
+ const data = await gargPost('/api/obsidian/sync', { vault_path: vaultPath });
194
+ console.log(`\x1b[32m✓\x1b[0m Synced: ${data.total_files} files (${data.ingested} new, ${data.skipped} unchanged)`);
195
+ if (data.error_count > 0) {
196
+ console.log(`\x1b[33m ⚠ ${data.error_count} errors\x1b[0m`);
197
+ }
198
+ }
199
+ catch (e) {
200
+ console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
201
+ }
202
+ }
203
+ // ── Main dispatcher ──────────────────────────────────────────────────────
204
+ export async function handleGargbotCommand(args) {
205
+ const subcommand = args[0] || 'help';
206
+ const subArgs = args.slice(1);
207
+ switch (subcommand) {
208
+ case 'chat':
209
+ return handleChat(subArgs);
210
+ case 'ingest':
211
+ return handleIngest(subArgs);
212
+ case 'search':
213
+ return handleSearch(subArgs);
214
+ case 'status':
215
+ return handleStatus(subArgs);
216
+ case 'sync-obsidian':
217
+ case 'sync':
218
+ return handleSyncObsidian(subArgs);
219
+ case 'help':
220
+ default:
221
+ console.log(`\x1b[36mGargBot CLI\x1b[0m — Knowledge management commands\n`);
222
+ console.log(' aither gargbot chat <message> Chat with GargBot');
223
+ console.log(' aither gargbot ingest <file-or-url> Ingest a document or URL');
224
+ console.log(' echo "text" | aither gargbot ingest Ingest from stdin');
225
+ console.log(' aither gargbot search <query> Search knowledge base');
226
+ console.log(' aither gargbot status Show health & stats');
227
+ console.log(' aither gargbot sync-obsidian <path> Sync Obsidian vault');
228
+ break;
229
+ }
230
+ }
package/dist/jobs.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Background Job Manager for AitherShell.
3
+ *
4
+ * Allows long-running tasks (forge, swarm, chat with agents) to execute
5
+ * in the background while the REPL remains interactive. Jobs collect their
6
+ * output silently and notify the shell on completion.
7
+ *
8
+ * IMPORTANT: Background jobs NEVER write to stdout/stderr directly.
9
+ * All output is collected into job.output[] and read via /jobs <id>.
10
+ * This prevents background tasks from corrupting the interactive prompt.
11
+ */
12
+ import type { GenesisClient, StreamChatOpts } from './client.js';
13
+ export type JobStatus = 'running' | 'completed' | 'failed' | 'cancelled';
14
+ export type JobKind = 'chat' | 'forge' | 'swarm' | 'command';
15
+ export interface Job {
16
+ id: number;
17
+ kind: JobKind;
18
+ label: string;
19
+ status: JobStatus;
20
+ startedAt: Date;
21
+ finishedAt: Date | null;
22
+ output: string[];
23
+ error: string | null;
24
+ abortController: AbortController | null;
25
+ }
26
+ type NotifyFn = (job: Job) => void;
27
+ /** Register a callback that fires when any background job completes. */
28
+ export declare function setJobNotifier(fn: NotifyFn): void;
29
+ /** Get all jobs (most recent first). */
30
+ export declare function listJobs(): Job[];
31
+ /** Get a single job by ID. */
32
+ export declare function getJob(id: number): Job | undefined;
33
+ /** Get count of currently running jobs. */
34
+ export declare function runningCount(): number;
35
+ /** Cancel a running job. */
36
+ export declare function cancelJob(id: number): boolean;
37
+ /** Prune completed/failed/cancelled jobs older than `maxAge` ms (default 30 min). */
38
+ export declare function pruneJobs(maxAge?: number): number;
39
+ /**
40
+ * Launch a streaming chat in the background.
41
+ * Returns the job immediately; the stream runs asynchronously.
42
+ *
43
+ * Consumes the SSE stream silently — no stdout writes, no spinners.
44
+ * Content and trace info are collected into job.output[].
45
+ */
46
+ export declare function launchChatJob(client: GenesisClient, message: string, opts?: StreamChatOpts & {
47
+ label?: string;
48
+ }): Job;
49
+ export declare function launchForgeJob(client: GenesisClient, task: string, opts?: {
50
+ agent?: string;
51
+ effort?: number;
52
+ }): Job;
53
+ export declare function launchSwarmJob(client: GenesisClient, task: string, mode?: string): Job;
54
+ /**
55
+ * Run an arbitrary async function as a background job.
56
+ * Replaces console.log within the function scope to capture output.
57
+ *
58
+ * WARNING: This uses global console.log replacement, so only ONE
59
+ * command job should run at a time. For concurrent background work,
60
+ * use the specific launchers (chat, forge, swarm) instead.
61
+ */
62
+ export declare function launchCommandJob(label: string, fn: () => Promise<void>): Job;
63
+ export declare function formatJobLine(job: Job): string;
64
+ export declare function formatJobOutput(job: Job): string;
65
+ export {};
package/dist/jobs.js ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Background Job Manager for AitherShell.
3
+ *
4
+ * Allows long-running tasks (forge, swarm, chat with agents) to execute
5
+ * in the background while the REPL remains interactive. Jobs collect their
6
+ * output silently and notify the shell on completion.
7
+ *
8
+ * IMPORTANT: Background jobs NEVER write to stdout/stderr directly.
9
+ * All output is collected into job.output[] and read via /jobs <id>.
10
+ * This prevents background tasks from corrupting the interactive prompt.
11
+ */
12
+ import chalk from 'chalk';
13
+ let nextJobId = 1;
14
+ const jobs = new Map();
15
+ let onJobDone = null;
16
+ /** Register a callback that fires when any background job completes. */
17
+ export function setJobNotifier(fn) {
18
+ onJobDone = fn;
19
+ }
20
+ /** Get all jobs (most recent first). */
21
+ export function listJobs() {
22
+ return [...jobs.values()].reverse();
23
+ }
24
+ /** Get a single job by ID. */
25
+ export function getJob(id) {
26
+ return jobs.get(id);
27
+ }
28
+ /** Get count of currently running jobs. */
29
+ export function runningCount() {
30
+ let count = 0;
31
+ for (const j of jobs.values()) {
32
+ if (j.status === 'running')
33
+ count++;
34
+ }
35
+ return count;
36
+ }
37
+ /** Cancel a running job. */
38
+ export function cancelJob(id) {
39
+ const job = jobs.get(id);
40
+ if (!job || job.status !== 'running')
41
+ return false;
42
+ if (job.abortController) {
43
+ job.abortController.abort();
44
+ }
45
+ job.status = 'cancelled';
46
+ job.finishedAt = new Date();
47
+ return true;
48
+ }
49
+ /** Prune completed/failed/cancelled jobs older than `maxAge` ms (default 30 min). */
50
+ export function pruneJobs(maxAge = 30 * 60 * 1000) {
51
+ const now = Date.now();
52
+ let pruned = 0;
53
+ for (const [id, job] of jobs) {
54
+ if (job.status !== 'running' && job.finishedAt && now - job.finishedAt.getTime() > maxAge) {
55
+ jobs.delete(id);
56
+ pruned++;
57
+ }
58
+ }
59
+ return pruned;
60
+ }
61
+ /* ── Job creation helpers ──────────────────────────────────── */
62
+ function createJob(kind, label) {
63
+ const job = {
64
+ id: nextJobId++,
65
+ kind,
66
+ label,
67
+ status: 'running',
68
+ startedAt: new Date(),
69
+ finishedAt: null,
70
+ output: [],
71
+ error: null,
72
+ abortController: new AbortController(),
73
+ };
74
+ jobs.set(job.id, job);
75
+ return job;
76
+ }
77
+ function finishJob(job, status, error) {
78
+ job.status = status;
79
+ job.finishedAt = new Date();
80
+ job.error = error || null;
81
+ job.abortController = null;
82
+ onJobDone?.(job);
83
+ }
84
+ /* ── Silent SSE content extractor ─────────────────────────── */
85
+ /**
86
+ * Extract the final text content from an SSE event stream without
87
+ * writing ANYTHING to stdout. No spinners, no progress — just data.
88
+ */
89
+ function extractContentFromEvent(event) {
90
+ switch (event.type) {
91
+ case 'token':
92
+ return event.data.t || null;
93
+ case 'message':
94
+ case 'answer':
95
+ case 'final_answer':
96
+ return event.data.response || event.data.answer || event.data.content || null;
97
+ case 'partial':
98
+ return event.data.content || event.data.text || null;
99
+ case 'done':
100
+ case 'complete':
101
+ return event.data.content || null;
102
+ default:
103
+ return null;
104
+ }
105
+ }
106
+ /**
107
+ * Extract metadata/trace info from SSE events for the job log.
108
+ */
109
+ function extractTraceFromEvent(event) {
110
+ switch (event.type) {
111
+ case 'thinking': {
112
+ const thought = event.data.thought || event.data.content || event.data.phase || '';
113
+ const clean = thought.replace(/<\/?think(?:ing)?>/g, '').trim();
114
+ if (clean.length > 10)
115
+ return `[think] ${clean.slice(0, 200)}`;
116
+ return null;
117
+ }
118
+ case 'tool_call': {
119
+ const tools = event.data.tools || event.data.tool_calls || [];
120
+ const names = tools.map((t) => t.name || t.function?.name || 'tool');
121
+ return names.length ? `[tools] ${names.join(', ')}` : null;
122
+ }
123
+ case 'tool_result': {
124
+ const results = event.data.results || [];
125
+ const summary = results.map((r) => {
126
+ const icon = r.success !== false ? '+' : 'x';
127
+ return `${icon}${r.tool || 'tool'}`;
128
+ });
129
+ return summary.length ? `[result] ${summary.join(', ')}` : null;
130
+ }
131
+ case 'plan_ready':
132
+ return event.data.summary ? `[plan] ${event.data.summary}` : null;
133
+ case 'error':
134
+ return `[error] ${event.data.error || 'unknown'}`;
135
+ case 'classify': {
136
+ const intent = event.data.intent?.type || '?';
137
+ const effort = event.data.effort?.level || '?';
138
+ return `[classify] intent=${intent} effort=${effort}`;
139
+ }
140
+ case 'classify_update': {
141
+ const uIntent = event.data.intent?.type || '?';
142
+ const uEffort = event.data.effort?.level || '?';
143
+ const uReason = event.data.reason || 'context';
144
+ return `[classify_update] intent=${uIntent} effort=${uEffort} reason=${uReason}`;
145
+ }
146
+ default:
147
+ return null;
148
+ }
149
+ }
150
+ /* ── Background chat ───────────────────────────────────────── */
151
+ /**
152
+ * Launch a streaming chat in the background.
153
+ * Returns the job immediately; the stream runs asynchronously.
154
+ *
155
+ * Consumes the SSE stream silently — no stdout writes, no spinners.
156
+ * Content and trace info are collected into job.output[].
157
+ */
158
+ export function launchChatJob(client, message, opts = {}) {
159
+ const label = opts.label || `Chat: ${message.slice(0, 50)}${message.length > 50 ? '...' : ''}`;
160
+ const job = createJob('chat', label);
161
+ const run = async () => {
162
+ const contentParts = [];
163
+ let fullAnswer = '';
164
+ // Each background chat gets its own session ID so concurrent jobs
165
+ // don't clobber each other's context on Genesis.
166
+ const bgSessionId = `${opts.sessionId || 'shell'}-bg-${job.id}-${Date.now().toString(36)}`;
167
+ try {
168
+ const stream = client.streamChat(message, {
169
+ ...opts,
170
+ sessionId: bgSessionId,
171
+ priority: 'background',
172
+ signal: job.abortController.signal,
173
+ });
174
+ for await (const event of stream) {
175
+ // Collect content tokens
176
+ const content = extractContentFromEvent(event);
177
+ if (content) {
178
+ // Token events are incremental, answer/message events are full text
179
+ if (event.type === 'token') {
180
+ contentParts.push(content);
181
+ }
182
+ else {
183
+ fullAnswer = content;
184
+ }
185
+ }
186
+ // Collect trace info for the job log
187
+ const trace = extractTraceFromEvent(event);
188
+ if (trace) {
189
+ job.output.push(trace);
190
+ }
191
+ // Extract metadata from done/complete
192
+ if (event.type === 'done' || event.type === 'complete') {
193
+ const meta = [];
194
+ if (event.data.model || event.data.model_used)
195
+ meta.push(event.data.model || event.data.model_used);
196
+ if (event.data.duration_ms || event.data.elapsed_ms) {
197
+ meta.push(`${Math.round(event.data.duration_ms || event.data.elapsed_ms)}ms`);
198
+ }
199
+ if (event.data.turns_completed)
200
+ meta.push(`${event.data.turns_completed} turns`);
201
+ if (meta.length) {
202
+ job.output.push(`[meta] ${meta.join(' | ')}`);
203
+ }
204
+ }
205
+ }
206
+ // Use full answer if available, otherwise join tokens
207
+ const finalContent = fullAnswer || contentParts.join('');
208
+ if (finalContent) {
209
+ job.output.push('---'); // separator between trace and content
210
+ job.output.push(finalContent);
211
+ }
212
+ finishJob(job, 'completed');
213
+ }
214
+ catch (err) {
215
+ if (err.name === 'AbortError') {
216
+ const partial = fullAnswer || contentParts.join('');
217
+ if (partial) {
218
+ job.output.push('---');
219
+ job.output.push(partial);
220
+ job.output.push('(cancelled — partial output above)');
221
+ }
222
+ finishJob(job, 'cancelled');
223
+ }
224
+ else {
225
+ finishJob(job, 'failed', err.message);
226
+ }
227
+ }
228
+ };
229
+ run().catch((err) => {
230
+ finishJob(job, 'failed', err.message);
231
+ });
232
+ return job;
233
+ }
234
+ /* ── Background forge ──────────────────────────────────────── */
235
+ export function launchForgeJob(client, task, opts = {}) {
236
+ const label = opts.agent
237
+ ? `Forge @${opts.agent}: ${task.slice(0, 40)}...`
238
+ : `Forge: ${task.slice(0, 50)}...`;
239
+ const job = createJob('forge', label);
240
+ const run = async () => {
241
+ try {
242
+ const result = await client.forgeDispatch(task, opts);
243
+ if (result?.error) {
244
+ job.output.push(result.error);
245
+ finishJob(job, 'failed', result.error);
246
+ }
247
+ else {
248
+ const output = result?.response || result?.result || result?.output;
249
+ job.output.push(output || JSON.stringify(result, null, 2));
250
+ finishJob(job, 'completed');
251
+ }
252
+ }
253
+ catch (err) {
254
+ finishJob(job, 'failed', err.message);
255
+ }
256
+ };
257
+ run().catch((err) => finishJob(job, 'failed', err.message));
258
+ return job;
259
+ }
260
+ /* ── Background swarm ──────────────────────────────────────── */
261
+ export function launchSwarmJob(client, task, mode = 'llm') {
262
+ const label = `Swarm (${mode}): ${task.slice(0, 40)}...`;
263
+ const job = createJob('swarm', label);
264
+ const run = async () => {
265
+ try {
266
+ const result = await client.post('/swarm/code/sync', { problem: task, mode });
267
+ if (result?.error) {
268
+ job.output.push(result.error);
269
+ finishJob(job, 'failed', result.error);
270
+ }
271
+ else {
272
+ const output = result?.result || result?.plan || result?.response;
273
+ job.output.push(typeof output === 'string' ? output : JSON.stringify(output, null, 2));
274
+ finishJob(job, 'completed');
275
+ }
276
+ }
277
+ catch (err) {
278
+ finishJob(job, 'failed', err.message);
279
+ }
280
+ };
281
+ run().catch((err) => finishJob(job, 'failed', err.message));
282
+ return job;
283
+ }
284
+ /* ── Background command ────────────────────────────────────── */
285
+ /**
286
+ * Run an arbitrary async function as a background job.
287
+ * Replaces console.log within the function scope to capture output.
288
+ *
289
+ * WARNING: This uses global console.log replacement, so only ONE
290
+ * command job should run at a time. For concurrent background work,
291
+ * use the specific launchers (chat, forge, swarm) instead.
292
+ */
293
+ export function launchCommandJob(label, fn) {
294
+ const job = createJob('command', label);
295
+ const run = async () => {
296
+ const origLog = console.log;
297
+ const origError = console.error;
298
+ const origWarn = console.warn;
299
+ const origWrite = process.stdout.write;
300
+ const capture = (...args) => {
301
+ const line = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
302
+ job.output.push(line);
303
+ };
304
+ const captureWrite = (chunk) => {
305
+ const text = typeof chunk === 'string' ? chunk : chunk.toString();
306
+ if (text.trim())
307
+ job.output.push(text);
308
+ return true;
309
+ };
310
+ // Replace all output channels
311
+ console.log = capture;
312
+ console.error = capture;
313
+ console.warn = capture;
314
+ process.stdout.write = captureWrite;
315
+ try {
316
+ await fn();
317
+ finishJob(job, 'completed');
318
+ }
319
+ catch (err) {
320
+ finishJob(job, 'failed', err.message);
321
+ }
322
+ finally {
323
+ // Always restore — even if the fn throws
324
+ console.log = origLog;
325
+ console.error = origError;
326
+ console.warn = origWarn;
327
+ process.stdout.write = origWrite;
328
+ }
329
+ };
330
+ run().catch((err) => {
331
+ finishJob(job, 'failed', err.message);
332
+ });
333
+ return job;
334
+ }
335
+ /* ── Formatting helpers ────────────────────────────────────── */
336
+ export function formatJobLine(job) {
337
+ const icons = {
338
+ running: chalk.blue('\u25B6'),
339
+ completed: chalk.green('\u2713'),
340
+ failed: chalk.red('\u2717'),
341
+ cancelled: chalk.yellow('\u2015'),
342
+ };
343
+ const icon = icons[job.status];
344
+ const id = chalk.bold(`#${job.id}`);
345
+ const elapsed = formatElapsed(job);
346
+ const status = job.status === 'running'
347
+ ? chalk.blue(job.status)
348
+ : job.status === 'completed'
349
+ ? chalk.green(job.status)
350
+ : job.status === 'failed'
351
+ ? chalk.red(job.status)
352
+ : chalk.yellow(job.status);
353
+ return ` ${icon} ${id} ${status} ${chalk.dim(elapsed)} ${job.label}`;
354
+ }
355
+ function formatElapsed(job) {
356
+ const end = job.finishedAt || new Date();
357
+ const ms = end.getTime() - job.startedAt.getTime();
358
+ if (ms < 1000)
359
+ return `${ms}ms`;
360
+ if (ms < 60_000)
361
+ return `${(ms / 1000).toFixed(1)}s`;
362
+ return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
363
+ }
364
+ export function formatJobOutput(job) {
365
+ const lines = [];
366
+ lines.push(chalk.bold(`\n Job #${job.id}: ${job.label}`));
367
+ lines.push(chalk.dim(` Status: ${job.status} | Started: ${job.startedAt.toLocaleTimeString()}`));
368
+ if (job.finishedAt) {
369
+ lines.push(chalk.dim(` Finished: ${job.finishedAt.toLocaleTimeString()} (${formatElapsed(job)})`));
370
+ }
371
+ if (job.error) {
372
+ lines.push(chalk.red(` Error: ${job.error}`));
373
+ }
374
+ if (job.output.length > 0) {
375
+ lines.push('');
376
+ const outputLines = job.output.join('\n').split('\n');
377
+ for (const line of outputLines) {
378
+ lines.push(` ${line}`);
379
+ }
380
+ }
381
+ else if (job.status === 'running') {
382
+ lines.push(chalk.dim(' (no output yet)'));
383
+ }
384
+ lines.push('');
385
+ return lines.join('\n');
386
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};