@cordfuse/llmux 0.11.0 → 0.12.1
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/README.md +155 -75
- package/dist/index.js +3268 -78
- package/package.json +17 -4
- package/src/cli.ts +100 -0
- package/src/{client.ts → client/client.ts} +3 -3
- package/src/daemon/agents.ts +193 -0
- package/src/daemon/auth-store.ts +85 -0
- package/src/daemon/config.ts +77 -0
- package/src/daemon/handlers.ts +414 -0
- package/src/daemon/net.ts +113 -0
- package/src/daemon/state.ts +78 -0
- package/src/daemon/tmux.ts +117 -0
- package/src/daemon/token.ts +13 -0
- package/src/daemon/web/server.ts +2277 -0
- package/src/index.ts +386 -37
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cordfuse/llmux",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.12.1",
|
|
4
|
+
"description": "tmux-based AI agent dispatcher — REST/WS daemon + CLI client in one binary",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -20,16 +20,29 @@
|
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
22
|
"start": "tsx src/index.ts",
|
|
23
|
-
"build": "tsup src/index.ts --target node20 --format esm --out-dir dist --clean",
|
|
23
|
+
"build": "tsup src/index.ts --target node20 --format esm --out-dir dist --external node-pty --external ws --external yaml --external qrcode-terminal --clean",
|
|
24
24
|
"prepublishOnly": "cp ../../README.md ../../LICENSE . && npm run build",
|
|
25
25
|
"postpublish": "rm -f README.md LICENSE",
|
|
26
26
|
"typecheck": "tsc --noEmit"
|
|
27
27
|
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@types/ws": "^8.18.1",
|
|
30
|
+
"node-pty": "^1.1.0",
|
|
31
|
+
"qrcode-terminal": "^0.12.0",
|
|
32
|
+
"ws": "^8.21.0",
|
|
33
|
+
"yaml": "^2.5.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/qrcode-terminal": "^0.12.2"
|
|
37
|
+
},
|
|
28
38
|
"keywords": [
|
|
29
39
|
"tmux",
|
|
30
40
|
"ai",
|
|
31
41
|
"agent",
|
|
32
|
-
"
|
|
42
|
+
"claude",
|
|
43
|
+
"codex",
|
|
44
|
+
"session-manager",
|
|
45
|
+
"llm",
|
|
33
46
|
"cordfuse"
|
|
34
47
|
],
|
|
35
48
|
"repository": {
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export type FlagKind = 'boolean' | 'string';
|
|
2
|
+
|
|
3
|
+
export interface FlagSpec {
|
|
4
|
+
kind: FlagKind;
|
|
5
|
+
alias?: string;
|
|
6
|
+
description: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type FlagSpecs = Record<string, FlagSpec>;
|
|
10
|
+
|
|
11
|
+
export interface ParsedArgs {
|
|
12
|
+
positional: string[];
|
|
13
|
+
flags: Record<string, string | boolean>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseArgs(argv: readonly string[], specs: FlagSpecs): ParsedArgs {
|
|
17
|
+
const aliasMap = new Map<string, string>();
|
|
18
|
+
for (const [name, spec] of Object.entries(specs)) {
|
|
19
|
+
if (spec.alias) aliasMap.set(spec.alias, name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const resolveName = (raw: string): string => aliasMap.get(raw) ?? raw;
|
|
23
|
+
|
|
24
|
+
const positional: string[] = [];
|
|
25
|
+
const flags: Record<string, string | boolean> = {};
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const token = argv[i]!;
|
|
29
|
+
|
|
30
|
+
if (token === '--') {
|
|
31
|
+
positional.push(...argv.slice(i + 1));
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (token.startsWith('--')) {
|
|
36
|
+
const body = token.slice(2);
|
|
37
|
+
const eq = body.indexOf('=');
|
|
38
|
+
const rawName = eq >= 0 ? body.slice(0, eq) : body;
|
|
39
|
+
const name = resolveName(rawName);
|
|
40
|
+
const spec = specs[name];
|
|
41
|
+
if (!spec) {
|
|
42
|
+
throw new Error(`unknown flag --${rawName}`);
|
|
43
|
+
}
|
|
44
|
+
if (spec.kind === 'boolean') {
|
|
45
|
+
flags[name] = eq >= 0 ? body.slice(eq + 1) !== 'false' : true;
|
|
46
|
+
} else {
|
|
47
|
+
if (eq >= 0) {
|
|
48
|
+
flags[name] = body.slice(eq + 1);
|
|
49
|
+
} else {
|
|
50
|
+
const next = argv[i + 1];
|
|
51
|
+
if (next === undefined || next.startsWith('-')) {
|
|
52
|
+
throw new Error(`--${rawName} requires a value`);
|
|
53
|
+
}
|
|
54
|
+
flags[name] = next;
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (token.startsWith('-') && token.length > 1) {
|
|
62
|
+
const body = token.slice(1);
|
|
63
|
+
const name = resolveName(body);
|
|
64
|
+
const spec = specs[name];
|
|
65
|
+
if (!spec) {
|
|
66
|
+
throw new Error(`unknown flag -${body}`);
|
|
67
|
+
}
|
|
68
|
+
if (spec.kind === 'boolean') {
|
|
69
|
+
flags[name] = true;
|
|
70
|
+
} else {
|
|
71
|
+
const next = argv[i + 1];
|
|
72
|
+
if (next === undefined || next.startsWith('-')) {
|
|
73
|
+
throw new Error(`-${body} requires a value`);
|
|
74
|
+
}
|
|
75
|
+
flags[name] = next;
|
|
76
|
+
i++;
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
positional.push(token);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { positional, flags };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function renderFlagHelp(specs: FlagSpecs): string {
|
|
88
|
+
const lines: string[] = [];
|
|
89
|
+
for (const [name, spec] of Object.entries(specs)) {
|
|
90
|
+
const lead = spec.alias ? `-${spec.alias}, --${name}` : ` --${name}`;
|
|
91
|
+
const value = spec.kind === 'string' ? ' <value>' : '';
|
|
92
|
+
lines.push(` ${(lead + value).padEnd(28)}${spec.description}`);
|
|
93
|
+
}
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function notImplemented(commandPath: string): never {
|
|
98
|
+
console.error(`llmux ${commandPath}: not yet implemented (scaffold)`);
|
|
99
|
+
process.exit(70);
|
|
100
|
+
}
|
|
@@ -18,7 +18,7 @@ function help(name: string, summary: string, usage: string): () => string {
|
|
|
18
18
|
` ${usage}`,
|
|
19
19
|
'',
|
|
20
20
|
'Environment:',
|
|
21
|
-
' LLMUX_SERVER base URL of the
|
|
21
|
+
' LLMUX_SERVER base URL of the llmux daemon (e.g. http://localhost:3030)',
|
|
22
22
|
' LLMUX_TOKEN auth token (sas_…); not required for localhost',
|
|
23
23
|
'',
|
|
24
24
|
].join('\n');
|
|
@@ -32,7 +32,7 @@ interface ClientContext {
|
|
|
32
32
|
export function resolveContext(): ClientContext {
|
|
33
33
|
const baseUrl = process.env.LLMUX_SERVER;
|
|
34
34
|
if (!baseUrl) {
|
|
35
|
-
throw new Error('LLMUX_SERVER is not set. Point it at your
|
|
35
|
+
throw new Error('LLMUX_SERVER is not set. Point it at your llmux daemon (e.g. http://localhost:3030).');
|
|
36
36
|
}
|
|
37
37
|
return { baseUrl: baseUrl.replace(/\/$/, ''), token: process.env.LLMUX_TOKEN };
|
|
38
38
|
}
|
|
@@ -62,7 +62,7 @@ async function request<T = unknown>(
|
|
|
62
62
|
}
|
|
63
63
|
if (r.status === 401) {
|
|
64
64
|
throw new Error(
|
|
65
|
-
'unauthorized — set LLMUX_TOKEN (use `
|
|
65
|
+
'unauthorized — set LLMUX_TOKEN (use `llmux token create` on the daemon host to mint one)',
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
if (r.status === 404) {
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, delimiter } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface Conversation {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
startedAt: string;
|
|
9
|
+
lastMessageAt: string;
|
|
10
|
+
messageCount: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AgentHistoryAdapter {
|
|
14
|
+
/** Past conversations for this agent in this cwd, newest-first. */
|
|
15
|
+
listConversations(cwd: string): Conversation[];
|
|
16
|
+
/** Build the launch flag fragment to resume a specific conversation. */
|
|
17
|
+
resumeFlag(conversationId: string): string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AgentDefinition {
|
|
21
|
+
/** Key under `agents:` in .llmux.yaml; default tmux-session name. */
|
|
22
|
+
key: string;
|
|
23
|
+
/** Human-readable name shown in UI surfaces (picker dropdown, etc.). */
|
|
24
|
+
displayName: string;
|
|
25
|
+
/** Executable to launch in the tmux pane. */
|
|
26
|
+
cmd: string;
|
|
27
|
+
/** Default args appended after `cmd`. */
|
|
28
|
+
flags?: string;
|
|
29
|
+
/** Regex matched against the bottom of the pane to detect "ready for input". */
|
|
30
|
+
readyPrompt: string;
|
|
31
|
+
/** Custom install detection (overrides the default PATH lookup). */
|
|
32
|
+
detectInstalled?: () => boolean;
|
|
33
|
+
/** One-line install command (shell). Shown in the agent-help modal. */
|
|
34
|
+
installHint?: string;
|
|
35
|
+
/** Homepage / docs URL. Shown alongside installHint as a fallback. */
|
|
36
|
+
docsUrl?: string;
|
|
37
|
+
/** Environment variables baked in at spawn time. Per-session env overrides win. */
|
|
38
|
+
envDefaults?: Record<string, string>;
|
|
39
|
+
/** Conversation-history adapter — enables the "resume past conversation" picker. */
|
|
40
|
+
history?: AgentHistoryAdapter;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Claude Code stores each conversation as a `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl`
|
|
45
|
+
* file. The encoding is a literal `/`-to-`-` substitution. Each line is a JSON
|
|
46
|
+
* event; the first user message defines the conversation's title, and the
|
|
47
|
+
* event timestamps frame `startedAt` / `lastMessageAt`. Synthetic events
|
|
48
|
+
* (permission-mode, local-command-stdout, /resume command stubs) are skipped
|
|
49
|
+
* when picking the title so the picker shows the actual conversation opener.
|
|
50
|
+
*/
|
|
51
|
+
function encodeClaudeCwd(cwd: string): string {
|
|
52
|
+
return cwd.replace(/\//g, '-');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractClaudeUserText(msg: unknown): string | undefined {
|
|
56
|
+
if (typeof msg !== 'object' || msg === null) return undefined;
|
|
57
|
+
const content = (msg as { content?: unknown }).content;
|
|
58
|
+
if (typeof content === 'string') return content;
|
|
59
|
+
if (Array.isArray(content)) {
|
|
60
|
+
for (const block of content) {
|
|
61
|
+
if (typeof block === 'object' && block !== null) {
|
|
62
|
+
const b = block as { type?: string; text?: string };
|
|
63
|
+
if (b.type === 'text' && typeof b.text === 'string') return b.text;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function looksLikeRealUserMessage(text: string): boolean {
|
|
71
|
+
if (!text) return false;
|
|
72
|
+
if (text.startsWith('<local-command')) return false;
|
|
73
|
+
if (text.startsWith('<command-name>')) return false;
|
|
74
|
+
if (text.startsWith('<command-message>')) return false;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const claudeHistory: AgentHistoryAdapter = {
|
|
79
|
+
listConversations(cwd: string): Conversation[] {
|
|
80
|
+
const dir = join(homedir(), '.claude', 'projects', encodeClaudeCwd(cwd));
|
|
81
|
+
if (!existsSync(dir)) return [];
|
|
82
|
+
let entries: string[];
|
|
83
|
+
try {
|
|
84
|
+
entries = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const out: Conversation[] = [];
|
|
89
|
+
for (const fname of entries) {
|
|
90
|
+
const id = fname.slice(0, -'.jsonl'.length);
|
|
91
|
+
const fpath = join(dir, fname);
|
|
92
|
+
try {
|
|
93
|
+
const raw = readFileSync(fpath, 'utf8');
|
|
94
|
+
const lines = raw.split('\n').filter((l) => l.length > 0);
|
|
95
|
+
let title: string | undefined;
|
|
96
|
+
let firstTs: string | undefined;
|
|
97
|
+
let lastTs: string | undefined;
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
let evt: { type?: string; timestamp?: string; message?: unknown };
|
|
100
|
+
try {
|
|
101
|
+
evt = JSON.parse(line);
|
|
102
|
+
} catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (evt.timestamp) {
|
|
106
|
+
if (!firstTs) firstTs = evt.timestamp;
|
|
107
|
+
lastTs = evt.timestamp;
|
|
108
|
+
}
|
|
109
|
+
if (!title && evt.type === 'user') {
|
|
110
|
+
const text = extractClaudeUserText(evt.message);
|
|
111
|
+
if (text && looksLikeRealUserMessage(text)) {
|
|
112
|
+
title = text.split('\n')[0]!.slice(0, 100).trim();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const stat = statSync(fpath);
|
|
117
|
+
out.push({
|
|
118
|
+
id,
|
|
119
|
+
title: title ?? '(no opener)',
|
|
120
|
+
startedAt: firstTs ?? new Date(stat.ctimeMs).toISOString(),
|
|
121
|
+
lastMessageAt: lastTs ?? new Date(stat.mtimeMs).toISOString(),
|
|
122
|
+
messageCount: lines.length,
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
// skip unreadable / malformed files
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out.sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt));
|
|
129
|
+
},
|
|
130
|
+
resumeFlag(id: string): string {
|
|
131
|
+
return `--resume ${id}`;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const which = (cmd: string): boolean => {
|
|
136
|
+
const pathDirs = (process.env.PATH ?? '').split(delimiter);
|
|
137
|
+
for (const dir of pathDirs) {
|
|
138
|
+
if (!dir) continue;
|
|
139
|
+
try {
|
|
140
|
+
accessSync(join(dir, cmd), constants.X_OK);
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
// not in this dir
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const copilotInstalled = (): boolean => {
|
|
150
|
+
// `gh copilot` is a built-in subcommand of gh 2.92+ (not an extension), and
|
|
151
|
+
// the actual Copilot CLI binary is downloaded on first invocation to
|
|
152
|
+
// ~/.local/share/gh/copilot. Treat the binary's presence as the install
|
|
153
|
+
// signal — `gh extension list` no longer surfaces copilot.
|
|
154
|
+
return existsSync(join(homedir(), '.local/share/gh/copilot'));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const DEFAULT_AGENTS: Record<string, AgentDefinition> = {
|
|
158
|
+
claude: { key: 'claude', displayName: 'Claude Code', cmd: 'claude', flags: '--dangerously-skip-permissions', readyPrompt: '^>', installHint: 'curl -fsSL https://claude.ai/install.sh | bash', docsUrl: 'https://docs.claude.com/en/docs/claude-code/overview', history: claudeHistory },
|
|
159
|
+
codex: { key: 'codex', displayName: 'Codex CLI', cmd: 'codex', flags: '--dangerously-bypass-approvals-and-sandbox', readyPrompt: '^>', installHint: 'npm install -g @openai/codex', docsUrl: 'https://github.com/openai/codex' },
|
|
160
|
+
agy: { key: 'agy', displayName: 'Antigravity CLI', cmd: 'agy', flags: '--dangerously-skip-permissions', readyPrompt: '^agy>', installHint: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', docsUrl: 'https://antigravity.google/docs/cli-install' },
|
|
161
|
+
gemini: { key: 'gemini', displayName: 'Gemini CLI', cmd: 'gemini', flags: '--yolo', readyPrompt: '^>', installHint: 'npm install -g @google/gemini-cli', docsUrl: 'https://github.com/google-gemini/gemini-cli' },
|
|
162
|
+
qwen: { key: 'qwen', displayName: 'Qwen Code', cmd: 'qwen', flags: '--yolo', readyPrompt: '^>', installHint: 'npm install -g @qwen-code/qwen-code', docsUrl: 'https://github.com/QwenLM/qwen-code' },
|
|
163
|
+
// OpenCode's --dangerously-skip-permissions only applies to `opencode run`
|
|
164
|
+
// (one-shot). The TUI default mode rejects it and exits — danger mode in
|
|
165
|
+
// the TUI is controlled via OPENCODE_YOLO=1 instead.
|
|
166
|
+
// No model flag set — OpenCode honors the operator's own config at
|
|
167
|
+
// ~/.config/opencode/opencode.json (provider + default model). Operator
|
|
168
|
+
// overrides per-spawn via the flags field if they want a specific model
|
|
169
|
+
// (e.g. `-m openrouter/anthropic/claude-sonnet-4.6` or
|
|
170
|
+
// `-m ollama/qwen2.5-coder:14b`).
|
|
171
|
+
opencode: { key: 'opencode', displayName: 'OpenCode', cmd: 'opencode', readyPrompt: '^>', installHint: 'curl -fsSL https://opencode.ai/install | bash', docsUrl: 'https://opencode.ai', envDefaults: { OPENCODE_YOLO: '1' } },
|
|
172
|
+
amp: { key: 'amp', displayName: 'Sourcegraph Amp', cmd: 'amp', flags: '--dangerously-allow-all', readyPrompt: '^>', installHint: 'npm install -g @sourcegraph/amp', docsUrl: 'https://ampcode.com/manual' },
|
|
173
|
+
grok: { key: 'grok', displayName: 'Grok Build CLI', cmd: 'grok', flags: '--always-approve', readyPrompt: '^grok>', installHint: 'curl -fsSL https://x.ai/cli/install.sh | bash', docsUrl: 'https://x.ai/cli' },
|
|
174
|
+
aider: { key: 'aider', displayName: 'Aider', cmd: 'aider', flags: '--yes-always --model claude-opus-4-6', readyPrompt: '^> $', installHint: 'python -m pip install aider-chat', docsUrl: 'https://aider.chat' },
|
|
175
|
+
continue: { key: 'continue', displayName: 'Continue CLI', cmd: 'cn', flags: '--auto', readyPrompt: '^>', installHint: 'npm install -g @continuedev/cli', docsUrl: 'https://docs.continue.dev/guides/cli' },
|
|
176
|
+
kiro: { key: 'kiro', displayName: 'Kiro CLI', cmd: 'kiro-cli', flags: '--trust-all-tools', readyPrompt: '^>', installHint: 'brew install kiro # or see docs for Linux/Windows', docsUrl: 'https://kiro.dev/docs/cli/installation/' },
|
|
177
|
+
cursor: { key: 'cursor', displayName: 'Cursor CLI', cmd: 'cursor-agent', readyPrompt: '^>', installHint: 'curl https://cursor.com/install -fsSL | bash', docsUrl: 'https://cursor.com/docs/cli/installation' },
|
|
178
|
+
plandex: { key: 'plandex', displayName: 'Plandex', cmd: 'plandex', readyPrompt: '^>', installHint: 'curl -fsSL https://plandex.ai/install.sh | bash', docsUrl: 'https://docs.plandex.ai' },
|
|
179
|
+
// goose has no launch flag — auto-approve is controlled via GOOSE_MODE=auto.
|
|
180
|
+
goose: { key: 'goose', displayName: 'Goose', cmd: 'goose', readyPrompt: 'Goose❯', installHint: 'curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | bash', docsUrl: 'https://block.github.io/goose', envDefaults: { GOOSE_MODE: 'auto' } },
|
|
181
|
+
copilot: { key: 'copilot', displayName: 'GitHub Copilot CLI', cmd: 'gh copilot', readyPrompt: '●', detectInstalled: copilotInstalled, installHint: 'gh copilot suggest "hi" # gh prerequisite; first run downloads', docsUrl: 'https://docs.github.com/en/copilot/how-tos/use-copilot-in-the-cli' },
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export function isAgentInstalled(agent: AgentDefinition): boolean {
|
|
185
|
+
if (agent.detectInstalled) return agent.detectInstalled();
|
|
186
|
+
// For multi-word commands, check only the first token.
|
|
187
|
+
const head = agent.cmd.split(/\s+/)[0]!;
|
|
188
|
+
return which(head);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function installedAgents(defs: Record<string, AgentDefinition> = DEFAULT_AGENTS): AgentDefinition[] {
|
|
192
|
+
return Object.values(defs).filter(isAgentInstalled);
|
|
193
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { stateDir } from './state.ts';
|
|
4
|
+
import { generateToken, tokenId } from './token.ts';
|
|
5
|
+
|
|
6
|
+
export interface AuthToken {
|
|
7
|
+
id: string;
|
|
8
|
+
token: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
expiresAt?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AuthFile {
|
|
15
|
+
version: 1;
|
|
16
|
+
tokens: AuthToken[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EMPTY: AuthFile = { version: 1, tokens: [] };
|
|
20
|
+
|
|
21
|
+
export function authPath(): string {
|
|
22
|
+
return join(stateDir(), 'auth.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function load(): AuthFile {
|
|
26
|
+
const p = authPath();
|
|
27
|
+
if (!existsSync(p)) return structuredClone(EMPTY);
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8')) as Partial<AuthFile>;
|
|
30
|
+
if (parsed.version === 1 && Array.isArray(parsed.tokens)) {
|
|
31
|
+
return { version: 1, tokens: parsed.tokens };
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
return structuredClone(EMPTY);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function save(file: AuthFile): void {
|
|
38
|
+
const p = authPath();
|
|
39
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
40
|
+
writeFileSync(p, JSON.stringify(file, null, 2) + '\n', { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function listAuthTokens(): AuthToken[] {
|
|
44
|
+
return load().tokens;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createAuthToken(opts: { name?: string; expiresAt?: string } = {}): AuthToken {
|
|
48
|
+
const token = generateToken();
|
|
49
|
+
const rec: AuthToken = {
|
|
50
|
+
id: tokenId(token),
|
|
51
|
+
token,
|
|
52
|
+
createdAt: new Date().toISOString(),
|
|
53
|
+
...(opts.name !== undefined ? { name: opts.name } : {}),
|
|
54
|
+
...(opts.expiresAt !== undefined ? { expiresAt: opts.expiresAt } : {}),
|
|
55
|
+
};
|
|
56
|
+
const file = load();
|
|
57
|
+
file.tokens.push(rec);
|
|
58
|
+
save(file);
|
|
59
|
+
return rec;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Revoke by id prefix (the 8-char display id). Returns true if anything was removed. */
|
|
63
|
+
export function revokeAuthToken(idPrefix: string): boolean {
|
|
64
|
+
const file = load();
|
|
65
|
+
const before = file.tokens.length;
|
|
66
|
+
file.tokens = file.tokens.filter((t) => t.id !== idPrefix);
|
|
67
|
+
if (file.tokens.length === before) return false;
|
|
68
|
+
save(file);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validateAuthToken(candidate: string | undefined): boolean {
|
|
73
|
+
if (!candidate) return false;
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
return load().tokens.some((t) => {
|
|
76
|
+
if (t.token !== candidate) return false;
|
|
77
|
+
if (t.expiresAt && new Date(t.expiresAt).getTime() < now) return false;
|
|
78
|
+
return true;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Auth is enabled when at least one token has been provisioned. */
|
|
83
|
+
export function authEnabled(): boolean {
|
|
84
|
+
return load().tokens.length > 0;
|
|
85
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
|
|
6
|
+
export interface ServerConfig {
|
|
7
|
+
port: number;
|
|
8
|
+
token?: string;
|
|
9
|
+
tokenExpiry?: string;
|
|
10
|
+
noQr: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SessionConfig {
|
|
14
|
+
name: string;
|
|
15
|
+
agent: string;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
autoSpawn: boolean;
|
|
18
|
+
restart: 'always' | 'on-failure' | 'never';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AgentOverrides {
|
|
22
|
+
cmd?: string;
|
|
23
|
+
flags?: string;
|
|
24
|
+
readyPrompt?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LlmuxConfig {
|
|
28
|
+
server: ServerConfig;
|
|
29
|
+
agents: Record<string, AgentOverrides>;
|
|
30
|
+
sessions: SessionConfig[];
|
|
31
|
+
sourcePath?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_CONFIG: LlmuxConfig = {
|
|
35
|
+
server: { port: 3000, noQr: false },
|
|
36
|
+
agents: {},
|
|
37
|
+
sessions: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface DiscoverOptions {
|
|
41
|
+
/** Path passed via `--config` (highest priority). */
|
|
42
|
+
explicit?: string;
|
|
43
|
+
/** Override cwd (defaults to process.cwd()). */
|
|
44
|
+
cwd?: string;
|
|
45
|
+
/** Override $HOME (defaults to os.homedir()). */
|
|
46
|
+
home?: string;
|
|
47
|
+
/** $LLMUX_CONFIG value (defaults to process.env.LLMUX_CONFIG). */
|
|
48
|
+
envVar?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resolve config path per discovery rules; null = no config, use defaults. */
|
|
52
|
+
export function discoverConfigPath(opts: DiscoverOptions = {}): string | null {
|
|
53
|
+
if (opts.explicit) return opts.explicit;
|
|
54
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
55
|
+
const projectLocal = join(cwd, '.llmux.yaml');
|
|
56
|
+
if (existsSync(projectLocal)) return projectLocal;
|
|
57
|
+
const home = opts.home ?? homedir();
|
|
58
|
+
const globalDefault = join(home, '.config', 'llmux', 'config.yaml');
|
|
59
|
+
if (existsSync(globalDefault)) return globalDefault;
|
|
60
|
+
const env = opts.envVar ?? process.env.LLMUX_CONFIG;
|
|
61
|
+
if (env && existsSync(env)) return env;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function loadConfig(opts: DiscoverOptions = {}): LlmuxConfig {
|
|
66
|
+
const path = discoverConfigPath(opts);
|
|
67
|
+
if (!path) return DEFAULT_CONFIG;
|
|
68
|
+
const raw = readFileSync(path, 'utf8');
|
|
69
|
+
const parsed = parseYaml(raw) as Partial<LlmuxConfig> | null;
|
|
70
|
+
const merged: LlmuxConfig = {
|
|
71
|
+
server: { ...DEFAULT_CONFIG.server, ...(parsed?.server ?? {}) },
|
|
72
|
+
agents: { ...DEFAULT_CONFIG.agents, ...(parsed?.agents ?? {}) },
|
|
73
|
+
sessions: parsed?.sessions ?? [],
|
|
74
|
+
sourcePath: path,
|
|
75
|
+
};
|
|
76
|
+
return merged;
|
|
77
|
+
}
|