mu-coding 0.15.0 → 0.16.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 +9 -123
- package/bin/coding-agent.ts +95 -0
- package/package.json +10 -21
- package/src/config.ts +122 -0
- package/src/harness.test.ts +159 -0
- package/src/main.ts +53 -3
- package/src/plugins.ts +49 -0
- package/src/systemPrompt.ts +22 -0
- package/src/ui/ChatApp.ts +959 -0
- package/src/ui/commands.ts +35 -0
- package/src/ui/editor.ts +166 -0
- package/src/ui/markdown.ts +363 -0
- package/src/ui/picker.ts +126 -0
- package/src/ui/status.ts +61 -0
- package/src/ui/theme.ts +241 -0
- package/src/ui/transcript.test.ts +121 -0
- package/src/ui/transcript.ts +399 -0
- package/tsconfig.json +8 -0
- package/bin/mu.js +0 -2
- package/prompts/SYSTEM.md +0 -16
- package/src/app/shutdown.ts +0 -94
- package/src/app/startApp.ts +0 -49
- package/src/cli/args.ts +0 -133
- package/src/cli/install.ts +0 -107
- package/src/cli/subcommands.ts +0 -29
- package/src/cli/update.ts +0 -205
- package/src/config/index.test.ts +0 -77
- package/src/config/index.ts +0 -199
- package/src/plugin.ts +0 -124
- package/src/runtime/codingTools/bash.ts +0 -114
- package/src/runtime/codingTools/edit-file.ts +0 -60
- package/src/runtime/codingTools/index.ts +0 -39
- package/src/runtime/codingTools/read-file.ts +0 -83
- package/src/runtime/codingTools/utils.ts +0 -21
- package/src/runtime/codingTools/write-file.ts +0 -42
- package/src/runtime/createRegistry.test.ts +0 -147
- package/src/runtime/createRegistry.ts +0 -195
- package/src/runtime/fileMentionProvider.ts +0 -117
- package/src/runtime/messageBus.test.ts +0 -62
- package/src/runtime/messageBus.ts +0 -78
- package/src/runtime/pluginLoader.ts +0 -153
- package/src/runtime/startupUpdateCheck.ts +0 -163
- package/src/runtime/updateCheck.ts +0 -136
- package/src/sessions/index.test.ts +0 -66
- package/src/sessions/index.ts +0 -183
- package/src/sessions/peek.test.ts +0 -88
- package/src/sessions/project.ts +0 -51
- package/src/tui/channel/tuiChannel.test.ts +0 -107
- package/src/tui/channel/tuiChannel.ts +0 -62
- package/src/tui/chat/ChatContext.ts +0 -10
- package/src/tui/chat/MessageRendererContext.ts +0 -44
- package/src/tui/chat/ToolDisplayContext.ts +0 -33
- package/src/tui/chat/useAbort.ts +0 -85
- package/src/tui/chat/useAttachment.ts +0 -74
- package/src/tui/chat/useChat.ts +0 -113
- package/src/tui/chat/useChatPanel.ts +0 -120
- package/src/tui/chat/useChatSession.ts +0 -384
- package/src/tui/chat/useModels.ts +0 -83
- package/src/tui/chat/usePluginStatus.ts +0 -44
- package/src/tui/chat/useSessionPersistence.ts +0 -84
- package/src/tui/chat/useStatusSegments.ts +0 -85
- package/src/tui/chat/useSubagentBrowser.ts +0 -133
- package/src/tui/components/chat/ChatPanel.tsx +0 -54
- package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
- package/src/tui/components/chat/Pickers.tsx +0 -44
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
- package/src/tui/components/messageView.tsx +0 -72
- package/src/tui/components/messages/EditOutput.tsx +0 -112
- package/src/tui/components/messages/ReadOutput.tsx +0 -48
- package/src/tui/components/messages/ToolHeader.tsx +0 -30
- package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
- package/src/tui/components/messages/WriteOutput.tsx +0 -64
- package/src/tui/components/messages/assistantMessage.tsx +0 -72
- package/src/tui/components/messages/markdown.tsx +0 -407
- package/src/tui/components/messages/messageItem.tsx +0 -43
- package/src/tui/components/messages/reasoningBlock.tsx +0 -18
- package/src/tui/components/messages/streamingOutput.tsx +0 -18
- package/src/tui/components/messages/toolCallBlock.tsx +0 -125
- package/src/tui/components/messages/userMessage.tsx +0 -44
- package/src/tui/components/primitives/dropdown.tsx +0 -125
- package/src/tui/components/primitives/modal.tsx +0 -47
- package/src/tui/components/primitives/pickerModal.tsx +0 -47
- package/src/tui/components/primitives/scrollbar.tsx +0 -27
- package/src/tui/components/primitives/toast.tsx +0 -100
- package/src/tui/components/statusBar.tsx +0 -41
- package/src/tui/components/ui/dialogLayer.tsx +0 -175
- package/src/tui/context/ThemeContext.tsx +0 -18
- package/src/tui/hooks/useChordKeyboard.ts +0 -87
- package/src/tui/hooks/useInputInfoSegments.ts +0 -22
- package/src/tui/hooks/useScroll.ts +0 -64
- package/src/tui/hooks/useTerminal.ts +0 -40
- package/src/tui/hooks/useUI.ts +0 -15
- package/src/tui/input/InputBox.tsx +0 -6
- package/src/tui/input/InputBoxView.tsx +0 -293
- package/src/tui/input/commands.test.ts +0 -71
- package/src/tui/input/commands.ts +0 -55
- package/src/tui/input/cursor.test.ts +0 -136
- package/src/tui/input/cursor.ts +0 -214
- package/src/tui/input/dumpContext.ts +0 -107
- package/src/tui/input/sanitize.ts +0 -33
- package/src/tui/input/useCommandExecutor.ts +0 -32
- package/src/tui/input/useInputBox.ts +0 -265
- package/src/tui/input/useInputHandler.ts +0 -455
- package/src/tui/input/useMentionPicker.ts +0 -133
- package/src/tui/input/usePluginShortcuts.ts +0 -29
- package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
- package/src/tui/plugins/InkApprovalChannel.ts +0 -30
- package/src/tui/plugins/InkUIService.ts +0 -188
- package/src/tui/renderApp.tsx +0 -66
- package/src/tui/theme/index.ts +0 -1
- package/src/tui/theme/merge.test.ts +0 -49
- package/src/tui/theme/merge.ts +0 -43
- package/src/tui/theme/presets.ts +0 -90
- package/src/tui/theme/types.ts +0 -138
- package/src/tui/update/runUpdateInTui.ts +0 -127
- package/src/utils/clipboard.ts +0 -97
- package/src/utils/diff.test.ts +0 -56
- package/src/utils/diff.ts +0 -81
package/README.md
CHANGED
|
@@ -1,125 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# coding-agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Terminal coding assistant built on `mu-core` + `mu-harness` + `mu-tui`. Uses
|
|
4
|
+
`mu-local-provider` (llama-swap) for inference and `mu-tools` for
|
|
5
|
+
filesystem + shell access. Sessions persist as JSONL under
|
|
6
|
+
`$XDG_DATA_HOME/coding-agent/sessions/`; plugins, skills, agents,
|
|
7
|
+
permissions load from `$XDG_CONFIG_HOME/coding-agent/`.
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
npm install -g mu-coding
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Usage
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
mu # Start interactive chat
|
|
15
|
-
mu -m model # Interactive with specific model
|
|
16
|
-
mu -c # Continue most recent session
|
|
17
|
-
mu --session <path> # Resume a specific session file
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
## Configuration
|
|
21
|
-
|
|
22
|
-
Config files follow XDG conventions:
|
|
23
|
-
|
|
24
|
-
| Path | Purpose |
|
|
25
|
-
|------|---------|
|
|
26
|
-
| `~/.config/mu/config.json` | Settings (baseUrl, model, maxTokens, temperature) |
|
|
27
|
-
| `~/.config/mu/SYSTEM.md` | System prompt |
|
|
28
|
-
| `~/.local/share/mu/sessions/` | Saved conversation sessions (JSONL) |
|
|
29
|
-
| `~/.cache/mu/repomap/` | Code index cache |
|
|
30
|
-
|
|
31
|
-
### Example `config.json`
|
|
32
|
-
|
|
33
|
-
```json
|
|
34
|
-
{
|
|
35
|
-
"baseUrl": "http://localhost:11434/v1",
|
|
36
|
-
"model": "qwen2.5",
|
|
37
|
-
"maxTokens": 4096,
|
|
38
|
-
"temperature": 0.7,
|
|
39
|
-
"streamTimeoutMs": 30000
|
|
40
|
-
}
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
### Theming
|
|
44
|
-
|
|
45
|
-
The `theme` field selects the UI palette. Either name a built-in preset:
|
|
46
|
-
|
|
47
|
-
```json
|
|
48
|
-
{ "theme": "solarized-dark" }
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Built-in presets: `dark` (default), `light`, `solarized-dark`, `monochrome`.
|
|
52
|
-
|
|
53
|
-
Or pass an object to override individual leaves on top of a preset:
|
|
54
|
-
|
|
55
|
-
```json
|
|
56
|
-
{
|
|
57
|
-
"theme": {
|
|
58
|
-
"preset": "dark",
|
|
59
|
-
"input": { "background": "#1e1e2e", "cursor": "#f5c2e7" },
|
|
60
|
-
"user": { "border": "magenta" },
|
|
61
|
-
"common": { "accent": "#89dceb" }
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Color values accept Ink's named colors (`red`, `green`, `cyan`, `yellow`,
|
|
67
|
-
`magenta`, `blue`, `white`, `black`, `gray`) or hex strings (`#1a1a1a`).
|
|
68
|
-
|
|
69
|
-
Sections available: `input`, `user`, `assistant`, `tool`, `reasoning`,
|
|
70
|
-
`modal`, `toast`, `dropdown`, `dialog`, `diff`, `status`, `common`. See
|
|
71
|
-
`src/tui/theme/types.ts` for the full leaf list.
|
|
72
|
-
|
|
73
|
-
## Keyboard Shortcuts
|
|
74
|
-
|
|
75
|
-
### Input editing
|
|
76
|
-
|
|
77
|
-
| Key | Action |
|
|
78
|
-
|-----|--------|
|
|
79
|
-
| `←` / `→` | Move cursor one character |
|
|
80
|
-
| `Ctrl+←` / `Ctrl+→` (or `Alt+←/→`) | Move cursor by word |
|
|
81
|
-
| `Home` / `End` (or `Ctrl+A` / `Ctrl+E`) | Jump to start / end of line |
|
|
82
|
-
| `↑` / `↓` | Move cursor between lines (multi-line buffer); navigate history at edges |
|
|
83
|
-
| `Backspace` | Delete char before cursor |
|
|
84
|
-
| `Delete` | Delete char under cursor |
|
|
85
|
-
| `Ctrl+W` | Delete previous word |
|
|
86
|
-
| `Ctrl+U` | Delete from start of line to cursor |
|
|
87
|
-
| `Ctrl+K` | Delete from cursor to end of line |
|
|
88
|
-
|
|
89
|
-
### Submission & app
|
|
90
|
-
|
|
91
|
-
| Key | Action |
|
|
92
|
-
|-----|--------|
|
|
93
|
-
| `Enter` | Send message |
|
|
94
|
-
| `Shift+Enter` (or `Ctrl+J`) | New line |
|
|
95
|
-
| `Ctrl+S` | Send message |
|
|
96
|
-
| `Ctrl+C` | Abort / Quit (press twice) |
|
|
97
|
-
| `Esc` | Stop generation (press twice) |
|
|
98
|
-
| `Ctrl+N` | New conversation |
|
|
99
|
-
| `Ctrl+M` | Cycle models |
|
|
100
|
-
| `Ctrl+O` | Model picker |
|
|
101
|
-
| `Ctrl+V` | Paste image from clipboard |
|
|
102
|
-
| `PageUp` / `PageDown` | Scroll |
|
|
103
|
-
| Mouse wheel | Scroll |
|
|
104
|
-
|
|
105
|
-
## Slash Commands
|
|
106
|
-
|
|
107
|
-
| Command | Action |
|
|
108
|
-
|---------|--------|
|
|
109
|
-
| `/model` | Select a model |
|
|
110
|
-
| `/sessions` | List project sessions |
|
|
111
|
-
| `/new` | New conversation |
|
|
112
|
-
|
|
113
|
-
## Features
|
|
114
|
-
|
|
115
|
-
- Streams responses with live token/s display
|
|
116
|
-
- Multi-turn tool calling (bash, read, write, edit files)
|
|
117
|
-
- Optional code indexing via the `mu-repomap` plugin (enable via `config.plugins`)
|
|
118
|
-
- Optional default agents (build/plan/explore/review) via `mu-coding-agents` (enable via `config.plugins`)
|
|
119
|
-
- Image attachment support
|
|
120
|
-
- Session persistence and resume
|
|
121
|
-
- Mouse wheel scrolling
|
|
122
|
-
|
|
123
|
-
## License
|
|
124
|
-
|
|
125
|
-
MIT
|
|
9
|
+
Run with `pnpm dev` (or `deno run -A --sloppy-imports bin/coding-agent.ts`).
|
|
10
|
+
`coding-agent install <npm:spec | path.ts>` adds a plugin;
|
|
11
|
+
`coding-agent -c` resumes the most recent session.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env -S deno run -A
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createHarness, loadAgents } from 'mu-harness';
|
|
5
|
+
import { createLocalProvider, listLocalModels } from 'mu-local-provider';
|
|
6
|
+
import { createMuTools } from 'mu-tools';
|
|
7
|
+
import { getConfigPath, loadConfig, loadState, xdgDirs } from '../src/config';
|
|
8
|
+
import { installPlugin, loadPlugins, uninstallPlugin } from '../src/plugins';
|
|
9
|
+
import { buildSystemPrompt } from '../src/systemPrompt';
|
|
10
|
+
import { runApp } from '../src/main';
|
|
11
|
+
|
|
12
|
+
const normalizeModel = (model?: string): string | undefined => {
|
|
13
|
+
if (!model) return undefined;
|
|
14
|
+
return model.includes('/') ? model : `local/${model}`;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function run(): Promise<void> {
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const [cmd, arg] = args;
|
|
20
|
+
|
|
21
|
+
if (cmd === 'install') {
|
|
22
|
+
if (!arg) throw new Error('usage: mu install <npm:spec | jsr:spec | ./path.ts>');
|
|
23
|
+
installPlugin(arg);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (cmd === 'uninstall') {
|
|
27
|
+
if (!arg) throw new Error('usage: mu uninstall <spec>');
|
|
28
|
+
uninstallPlugin(arg);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const wantContinue = args.includes('-c') || args.includes('--continue');
|
|
33
|
+
|
|
34
|
+
const config = loadConfig();
|
|
35
|
+
const state = loadState();
|
|
36
|
+
|
|
37
|
+
if (!config.baseUrl) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Missing baseUrl in config. Create ${getConfigPath()} with { "kind": "llama-swap", "baseUrl": "http://..." }`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const xdg = xdgDirs();
|
|
44
|
+
const cwd = process.cwd();
|
|
45
|
+
const projectLocal = join(cwd, '.mu');
|
|
46
|
+
const providerConfig = { kind: config.kind, baseUrl: config.baseUrl, apiKey: config.apiKey };
|
|
47
|
+
|
|
48
|
+
let initialRef = normalizeModel(state.model);
|
|
49
|
+
if (!initialRef) {
|
|
50
|
+
try {
|
|
51
|
+
const models = await listLocalModels(providerConfig);
|
|
52
|
+
initialRef = models[0] ? `local/${models[0].id}` : 'local/default';
|
|
53
|
+
} catch {
|
|
54
|
+
initialRef = 'local/default';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const plugins = await loadPlugins(config.plugins);
|
|
59
|
+
|
|
60
|
+
const diskAgents = await loadAgents(join(xdg.configHome, 'mu', 'agents'));
|
|
61
|
+
const projectAgents = await loadAgents(join(projectLocal, 'agents'));
|
|
62
|
+
const promptAgents = [...projectAgents, ...diskAgents, ...plugins.flatMap((p) => p.agents ?? [])];
|
|
63
|
+
|
|
64
|
+
const harness = await createHarness({
|
|
65
|
+
hostName: 'mu',
|
|
66
|
+
xdg,
|
|
67
|
+
cwd,
|
|
68
|
+
providers: { local: createLocalProvider(providerConfig) },
|
|
69
|
+
model: initialRef,
|
|
70
|
+
tools: createMuTools({ getCwd: () => cwd }),
|
|
71
|
+
plugins,
|
|
72
|
+
agents: projectAgents,
|
|
73
|
+
system: buildSystemPrompt(promptAgents),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let session;
|
|
77
|
+
if (wantContinue) {
|
|
78
|
+
const recent = await harness.sessions.list({ cwd });
|
|
79
|
+
if (recent[0]) {
|
|
80
|
+
session = await harness.sessions.open(recent[0].id);
|
|
81
|
+
} else {
|
|
82
|
+
process.stderr.write('[mu] no previous session to resume; starting a new one\n');
|
|
83
|
+
session = harness.sessions.create();
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
session = harness.sessions.create();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await runApp({ harness, session, providerConfig, state });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
run().catch((err) => {
|
|
93
|
+
console.error(err);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
});
|
package/package.json
CHANGED
|
@@ -1,32 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mu-coding",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Minimal terminal AI assistant for local models",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"mu": "./bin/
|
|
7
|
+
"mu": "./bin/coding-agent.ts"
|
|
8
8
|
},
|
|
9
|
-
"exports": {
|
|
10
|
-
".": "./src/plugin.ts",
|
|
11
|
-
"./config": "./src/config/index.ts",
|
|
12
|
-
"./tools": "./src/runtime/codingTools/index.ts",
|
|
13
|
-
"./loader": "./src/runtime/pluginLoader.ts"
|
|
14
|
-
},
|
|
15
|
-
"files": [
|
|
16
|
-
"bin",
|
|
17
|
-
"prompts",
|
|
18
|
-
"src"
|
|
19
|
-
],
|
|
20
9
|
"scripts": {
|
|
21
|
-
"dev": "
|
|
22
|
-
"
|
|
23
|
-
"
|
|
10
|
+
"dev": "NODE_ENV=development deno run -A --sloppy-imports bin/coding-agent.ts",
|
|
11
|
+
"dev:tui-debug": "MU_TUI_DEBUG_LOG=/tmp/mu-tui.log NODE_ENV=development deno run -A --sloppy-imports bin/coding-agent.ts",
|
|
12
|
+
"start": "deno run -A --sloppy-imports bin/coding-agent.ts"
|
|
24
13
|
},
|
|
25
14
|
"dependencies": {
|
|
26
|
-
"
|
|
27
|
-
"mu-
|
|
28
|
-
"mu-
|
|
29
|
-
"mu-
|
|
30
|
-
"
|
|
15
|
+
"mu-core": "workspace:*",
|
|
16
|
+
"mu-harness": "workspace:*",
|
|
17
|
+
"mu-local-provider": "workspace:*",
|
|
18
|
+
"mu-tools": "workspace:*",
|
|
19
|
+
"mu-tui": "workspace:*"
|
|
31
20
|
}
|
|
32
21
|
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import type { XdgDirs } from 'mu-harness';
|
|
6
|
+
|
|
7
|
+
const HOST = 'mu';
|
|
8
|
+
|
|
9
|
+
const env = (name: string): string | undefined => {
|
|
10
|
+
const value = process.env[name];
|
|
11
|
+
return value && value.length > 0 ? value : undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function xdgDirs(): XdgDirs {
|
|
15
|
+
const home = homedir();
|
|
16
|
+
return {
|
|
17
|
+
configHome: env('XDG_CONFIG_HOME') ?? join(home, '.config'),
|
|
18
|
+
dataHome: env('XDG_DATA_HOME') ?? join(home, '.local', 'share'),
|
|
19
|
+
stateHome: env('XDG_STATE_HOME') ?? join(home, '.local', 'state'),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const xdg = xdgDirs();
|
|
24
|
+
const configDir = join(xdg.configHome, HOST);
|
|
25
|
+
const stateDir = join(xdg.stateHome, HOST);
|
|
26
|
+
|
|
27
|
+
export const paths = {
|
|
28
|
+
configFile: join(configDir, 'config.json'),
|
|
29
|
+
stateFile: join(stateDir, 'state.json'),
|
|
30
|
+
historyFile: join(stateDir, 'history.json'),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface CodingAgentConfig {
|
|
34
|
+
kind?: string;
|
|
35
|
+
baseUrl?: string;
|
|
36
|
+
apiKey?: string;
|
|
37
|
+
plugins?: string[];
|
|
38
|
+
provider?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CodingAgentState {
|
|
42
|
+
model?: string;
|
|
43
|
+
thinkingVisible?: boolean;
|
|
44
|
+
theme?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const readJson = (path: string): Record<string, unknown> => {
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(path, 'utf-8');
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : {};
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
54
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
55
|
+
process.stderr.write(`[coding-agent] failed to read ${path}: ${msg}\n`);
|
|
56
|
+
}
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const writeJson = (path: string, value: unknown): void => {
|
|
62
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
63
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function getConfigPath(): string {
|
|
67
|
+
return paths.configFile;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function loadConfig(): CodingAgentConfig {
|
|
71
|
+
const obj = readJson(paths.configFile);
|
|
72
|
+
const out: CodingAgentConfig = {};
|
|
73
|
+
if (typeof obj.kind === 'string') out.kind = obj.kind;
|
|
74
|
+
if (typeof obj.baseUrl === 'string') out.baseUrl = obj.baseUrl;
|
|
75
|
+
if (typeof obj.apiKey === 'string') out.apiKey = obj.apiKey;
|
|
76
|
+
if (typeof obj.provider === 'string') out.provider = obj.provider;
|
|
77
|
+
if (Array.isArray(obj.plugins) && obj.plugins.every((p) => typeof p === 'string')) {
|
|
78
|
+
out.plugins = obj.plugins as string[];
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function saveConfig(config: CodingAgentConfig): void {
|
|
84
|
+
writeJson(paths.configFile, config);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function loadState(): CodingAgentState {
|
|
88
|
+
const obj = readJson(paths.stateFile);
|
|
89
|
+
const out: CodingAgentState = {};
|
|
90
|
+
if (typeof obj.model === 'string') out.model = obj.model;
|
|
91
|
+
if (typeof obj.thinkingVisible === 'boolean') out.thinkingVisible = obj.thinkingVisible;
|
|
92
|
+
if (typeof obj.theme === 'string') out.theme = obj.theme;
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function saveState(state: CodingAgentState): void {
|
|
97
|
+
try {
|
|
98
|
+
writeJson(paths.stateFile, state);
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const HISTORY_MAX = 500;
|
|
104
|
+
|
|
105
|
+
export function loadHistory(): string[] {
|
|
106
|
+
const obj = readJson(paths.historyFile);
|
|
107
|
+
const entries = (obj as { entries?: unknown }).entries;
|
|
108
|
+
if (Array.isArray(entries)) return entries.filter((e): e is string => typeof e === 'string');
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function appendHistory(entry: string): void {
|
|
113
|
+
if (!entry.trim()) return;
|
|
114
|
+
const entries = loadHistory();
|
|
115
|
+
if (entries[entries.length - 1] === entry) return;
|
|
116
|
+
entries.push(entry);
|
|
117
|
+
const trimmed = entries.slice(-HISTORY_MAX);
|
|
118
|
+
try {
|
|
119
|
+
writeJson(paths.historyFile, { entries: trimmed });
|
|
120
|
+
} catch {
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { expect } from '@std/expect';
|
|
2
|
+
import { describe, it } from '@std/testing/bdd';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import type { Message, Provider, StreamEvent, Tool } from 'mu-core';
|
|
7
|
+
import { type AgentSessionEvent, createHarness, type XdgDirs } from 'mu-harness';
|
|
8
|
+
import { createMuTools } from 'mu-tools';
|
|
9
|
+
|
|
10
|
+
const tempXdg = (): { xdg: XdgDirs; dir: string } => {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), 'mu-smoke-'));
|
|
12
|
+
return { dir, xdg: { configHome: join(dir, 'config'), dataHome: join(dir, 'data'), stateHome: join(dir, 'state') } };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const stubProvider = (parts: StreamEvent[]): Provider => ({
|
|
16
|
+
async *stream() {
|
|
17
|
+
for (const part of parts) yield part;
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('coding-agent harness wiring', () => {
|
|
22
|
+
it('runs a streamed turn via a created session', async () => {
|
|
23
|
+
const { dir, xdg } = tempXdg();
|
|
24
|
+
const harness = await createHarness({
|
|
25
|
+
hostName: 'mu-test',
|
|
26
|
+
xdg,
|
|
27
|
+
providers: { local: stubProvider([{ type: 'text', text: 'Hello ' }, { type: 'text', text: 'world' }]) },
|
|
28
|
+
model: 'local/test-model',
|
|
29
|
+
tools: createMuTools(),
|
|
30
|
+
system: 'You are a test.',
|
|
31
|
+
title: false,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const session = harness.sessions.create();
|
|
36
|
+
const events: AgentSessionEvent[] = [];
|
|
37
|
+
session.subscribe((event) => events.push(event));
|
|
38
|
+
|
|
39
|
+
await session.send('hi');
|
|
40
|
+
|
|
41
|
+
const types = events.map((e) => e.type);
|
|
42
|
+
expect(types[0]).toBe('turn_start');
|
|
43
|
+
expect(types).toContain('text');
|
|
44
|
+
expect(types[types.length - 1]).toBe('turn_end');
|
|
45
|
+
|
|
46
|
+
const assistant = session.messages.find((m) => m.role === 'assistant');
|
|
47
|
+
const assistantText = assistant?.content.map((p) => (p.type === 'text' ? p.text : '')).join('');
|
|
48
|
+
expect(assistantText).toBe('Hello world');
|
|
49
|
+
} finally {
|
|
50
|
+
harness.close();
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('exposes the provider usage events via the session', async () => {
|
|
56
|
+
const { dir, xdg } = tempXdg();
|
|
57
|
+
const harness = await createHarness({
|
|
58
|
+
hostName: 'mu-test',
|
|
59
|
+
xdg,
|
|
60
|
+
providers: {
|
|
61
|
+
local: stubProvider([
|
|
62
|
+
{ type: 'text', text: 'ok' },
|
|
63
|
+
{ type: 'usage', usage: { input: 1200, output: 50, total: 1250, contextWindow: 32768 } },
|
|
64
|
+
]),
|
|
65
|
+
},
|
|
66
|
+
model: 'local/test-model',
|
|
67
|
+
tools: createMuTools(),
|
|
68
|
+
system: 'You are a test.',
|
|
69
|
+
title: false,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const session = harness.sessions.create();
|
|
74
|
+
const usageEvents: { input?: number; total?: number; contextWindow?: number }[] = [];
|
|
75
|
+
session.subscribe((event) => {
|
|
76
|
+
if (event.type === 'usage') usageEvents.push(event.usage);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await session.send('hi');
|
|
80
|
+
|
|
81
|
+
expect(usageEvents.length).toBe(1);
|
|
82
|
+
expect(usageEvents[0].total).toBe(1250);
|
|
83
|
+
expect(usageEvents[0].contextWindow).toBe(32768);
|
|
84
|
+
const assistant = session.messages.find((m) => m.role === 'assistant');
|
|
85
|
+
expect(assistant?.content).toEqual([{ type: 'text', text: 'ok' }]);
|
|
86
|
+
} finally {
|
|
87
|
+
harness.close();
|
|
88
|
+
rmSync(dir, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('adds the per-tool prompts to the system message sent to the model', async () => {
|
|
93
|
+
const { dir, xdg } = tempXdg();
|
|
94
|
+
let captured: Message[] = [];
|
|
95
|
+
const provider: Provider = {
|
|
96
|
+
async *stream(req) {
|
|
97
|
+
captured = req.messages;
|
|
98
|
+
yield { type: 'text', text: 'ok' };
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const tool: Tool = {
|
|
102
|
+
name: 'demo',
|
|
103
|
+
description: 'demo tool',
|
|
104
|
+
prompt: 'DEMO_TOOL_PROMPT',
|
|
105
|
+
parameters: { type: 'object' },
|
|
106
|
+
run: () => Promise.resolve([{ type: 'text', text: 'x' }]),
|
|
107
|
+
};
|
|
108
|
+
const harness = await createHarness({
|
|
109
|
+
hostName: 'mu-test',
|
|
110
|
+
xdg,
|
|
111
|
+
providers: { local: provider },
|
|
112
|
+
model: 'local/test-model',
|
|
113
|
+
tools: [tool],
|
|
114
|
+
system: 'BASE_SYS',
|
|
115
|
+
title: false,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await harness.sessions.create().send('hi');
|
|
120
|
+
const system = captured.find((m) => m.role === 'system');
|
|
121
|
+
const text = system?.content.map((p) => (p.type === 'text' ? p.text : '')).join('') ?? '';
|
|
122
|
+
expect(text).toContain('BASE_SYS');
|
|
123
|
+
expect(text).toContain('DEMO_TOOL_PROMPT');
|
|
124
|
+
} finally {
|
|
125
|
+
harness.close();
|
|
126
|
+
rmSync(dir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('switches model and forks a session preserving the prior history', async () => {
|
|
131
|
+
const { dir, xdg } = tempXdg();
|
|
132
|
+
const harness = await createHarness({
|
|
133
|
+
hostName: 'mu-test',
|
|
134
|
+
xdg,
|
|
135
|
+
providers: { local: stubProvider([{ type: 'text', text: 'ok' }]) },
|
|
136
|
+
model: 'local/model-a',
|
|
137
|
+
tools: createMuTools(),
|
|
138
|
+
system: 'You are a test.',
|
|
139
|
+
title: false,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const session = harness.sessions.create();
|
|
144
|
+
await session.send('first');
|
|
145
|
+
expect(harness.models.selected).toBe('local/model-a');
|
|
146
|
+
|
|
147
|
+
harness.models.select('local/model-b');
|
|
148
|
+
const forked = await harness.sessions.fork(session.id, session.messages.length - 1);
|
|
149
|
+
expect(forked.id).not.toBe(session.id);
|
|
150
|
+
const userTexts = forked.messages
|
|
151
|
+
.filter((m) => m.role === 'user')
|
|
152
|
+
.map((m) => m.content.map((p) => (p.type === 'text' ? p.text : '')).join(''));
|
|
153
|
+
expect(userTexts).toContain('first');
|
|
154
|
+
} finally {
|
|
155
|
+
harness.close();
|
|
156
|
+
rmSync(dir, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
package/src/main.ts
CHANGED
|
@@ -1,4 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import type { AgentSession, Harness } from 'mu-harness';
|
|
3
|
+
import { listLocalModels } from 'mu-local-provider';
|
|
4
|
+
import { type CodingAgentState, saveState } from './config';
|
|
5
|
+
import { ChatApp, type ChatHost } from './ui/ChatApp';
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
export interface RunAppOptions {
|
|
8
|
+
harness: Harness;
|
|
9
|
+
session: AgentSession;
|
|
10
|
+
providerConfig: { kind?: string; baseUrl?: string; apiKey?: string };
|
|
11
|
+
state: CodingAgentState;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function runApp(opts: RunAppOptions): Promise<void> {
|
|
15
|
+
const { harness, providerConfig, state } = opts;
|
|
16
|
+
|
|
17
|
+
const host: ChatHost = {
|
|
18
|
+
session: opts.session,
|
|
19
|
+
cwd: harness.cwd,
|
|
20
|
+
createSession: () => harness.sessions.create(),
|
|
21
|
+
forkSession: (id, upToIndex) => harness.sessions.fork(id, upToIndex),
|
|
22
|
+
selectModel: (ref) => {
|
|
23
|
+
harness.models.select(ref);
|
|
24
|
+
state.model = ref;
|
|
25
|
+
saveState(state);
|
|
26
|
+
},
|
|
27
|
+
modelRef: () => harness.models.selected,
|
|
28
|
+
listModels: () => listLocalModels(providerConfig),
|
|
29
|
+
agentNames: () => harness.agents.list().map((agent) => agent.name).filter((name) => name !== 'title'),
|
|
30
|
+
subAgents: harness.subAgents,
|
|
31
|
+
dispatchSubAgent: (agent, task, parentId) => harness.dispatchSubAgent(agent, task, parentId),
|
|
32
|
+
initialTheme: state.theme ?? 'dark',
|
|
33
|
+
saveTheme: (name) => {
|
|
34
|
+
state.theme = name;
|
|
35
|
+
saveState(state);
|
|
36
|
+
},
|
|
37
|
+
initialThinking: state.thinkingVisible ?? false,
|
|
38
|
+
saveThinking: (visible) => {
|
|
39
|
+
state.thinkingVisible = visible;
|
|
40
|
+
saveState(state);
|
|
41
|
+
},
|
|
42
|
+
onExit: (code) => {
|
|
43
|
+
harness.close();
|
|
44
|
+
process.exit(code);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const app = new ChatApp(host);
|
|
49
|
+
|
|
50
|
+
process.on('SIGINT', () => void app.stop().then(() => process.exit(130)));
|
|
51
|
+
process.on('SIGTERM', () => void app.stop().then(() => process.exit(143)));
|
|
52
|
+
|
|
53
|
+
await app.start();
|
|
54
|
+
}
|
package/src/plugins.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import type { Plugin } from 'mu-harness';
|
|
3
|
+
import { loadConfig, saveConfig } from './config';
|
|
4
|
+
|
|
5
|
+
const isPlugin = (value: unknown): value is Plugin =>
|
|
6
|
+
typeof value === 'object' && value !== null && typeof (value as { name?: unknown }).name === 'string';
|
|
7
|
+
|
|
8
|
+
export async function loadPlugins(specs: string[] = []): Promise<Plugin[]> {
|
|
9
|
+
const plugins: Plugin[] = [];
|
|
10
|
+
for (const spec of specs) {
|
|
11
|
+
try {
|
|
12
|
+
const mod = await import(spec) as Record<string, unknown>;
|
|
13
|
+
const candidate = mod.default ?? mod.plugin ?? mod;
|
|
14
|
+
if (isPlugin(candidate)) {
|
|
15
|
+
plugins.push(candidate);
|
|
16
|
+
} else {
|
|
17
|
+
process.stderr.write(`[mu] plugin "${spec}" has no valid default export (expected { name, ... })\n`);
|
|
18
|
+
}
|
|
19
|
+
} catch (err) {
|
|
20
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
21
|
+
process.stderr.write(`[mu] failed to load plugin "${spec}": ${msg}\n`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return plugins;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function installPlugin(spec: string): void {
|
|
28
|
+
const config = loadConfig();
|
|
29
|
+
const plugins = config.plugins ?? [];
|
|
30
|
+
if (plugins.includes(spec)) {
|
|
31
|
+
process.stdout.write(`[mu] plugin already registered: ${spec}\n`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
config.plugins = [...plugins, spec];
|
|
35
|
+
saveConfig(config);
|
|
36
|
+
process.stdout.write(`[mu] registered plugin: ${spec}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function uninstallPlugin(spec: string): void {
|
|
40
|
+
const config = loadConfig();
|
|
41
|
+
const plugins = config.plugins ?? [];
|
|
42
|
+
if (!plugins.includes(spec)) {
|
|
43
|
+
process.stderr.write(`[mu] plugin not registered: ${spec}\n`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
config.plugins = plugins.filter((p) => p !== spec);
|
|
47
|
+
saveConfig(config);
|
|
48
|
+
process.stdout.write(`[mu] unregistered plugin: ${spec}\n`);
|
|
49
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Agent } from 'mu-harness';
|
|
2
|
+
|
|
3
|
+
const BASE = `You are mu, a terminal coding assistant operating inside the user's project directory.
|
|
4
|
+
|
|
5
|
+
Guidelines:
|
|
6
|
+
- Be concise. The user is in a terminal; avoid filler and long preambles.
|
|
7
|
+
- Inspect before you act: read the relevant files and run commands to confirm assumptions instead of guessing.
|
|
8
|
+
- After changing code, run the project's checks/tests when feasible and report the real outcome.
|
|
9
|
+
- Never invent file paths, APIs, or command output. If a command fails, say so with the error.
|
|
10
|
+
- Use GitHub-flavored Markdown in replies; reference code as path:line.`;
|
|
11
|
+
|
|
12
|
+
export function buildSystemPrompt(agents: Agent[]): string {
|
|
13
|
+
const usable = agents.filter((agent) => agent.name !== 'title');
|
|
14
|
+
if (usable.length === 0) return BASE;
|
|
15
|
+
const list = usable
|
|
16
|
+
.map((agent) => `- ${agent.name}: ${agent.description || 'no description'}`)
|
|
17
|
+
.join('\n');
|
|
18
|
+
return `${BASE}
|
|
19
|
+
|
|
20
|
+
Available sub-agents (delegate with the \`subagent\` tool):
|
|
21
|
+
${list}`;
|
|
22
|
+
}
|