@bahulam/code 0.1.10 → 0.1.12
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/package.json +5 -2
- package/src/agents/loader.mjs +26 -4
- package/src/auth/bahulam-auth.mjs +2 -13
- package/src/auth/tarang-auth.mjs +313 -0
- package/src/commands/agent.mjs +7 -7
- package/src/commands/plugin-manage.mjs +449 -0
- package/src/commands/plugin.mjs +247 -0
- package/src/config/cli-args.mjs +16 -0
- package/src/config/env.mjs +2 -0
- package/src/config/hook-runner.mjs +8 -8
- package/src/config/memory-loader.mjs +7 -3
- package/src/config/settings-loader.mjs +5 -3
- package/src/core/attachments.mjs +2 -2
- package/src/core/background-tasks.mjs +186 -0
- package/src/core/headless.mjs +59 -3
- package/src/core/local-agent.mjs +10 -1
- package/src/core/local-store.mjs +10 -10
- package/src/core/paths.mjs +10 -96
- package/src/core/policy-resolver.mjs +1 -1
- package/src/core/project-context-loader.mjs +2 -2
- package/src/core/risk-tier.mjs +1 -0
- package/src/core/stream-client.mjs +148 -1
- package/src/core/system-prompt.mjs +31 -12
- package/src/core/tool-executor.mjs +457 -12
- package/src/local-service/agent-relay.mjs +139 -12
- package/src/local-service/server.mjs +345 -20
- package/src/orchestration/approval.mjs +30 -0
- package/src/orchestration/completion-triggers.mjs +40 -0
- package/src/orchestration/dispatch.mjs +118 -0
- package/src/orchestration/events.mjs +19 -0
- package/src/orchestration/graph.mjs +126 -0
- package/src/orchestration/node-runner.mjs +193 -0
- package/src/orchestration/runner.mjs +200 -0
- package/src/plugins/executor.mjs +121 -0
- package/src/plugins/loader.mjs +123 -123
- package/src/plugins/manifest.mjs +291 -0
- package/src/plugins/preflight.mjs +227 -0
- package/src/plugins/registry.mjs +233 -0
- package/src/plugins/state.mjs +290 -0
- package/src/terminal/agents.mjs +26 -4
- package/src/terminal/init.mjs +2 -2
- package/src/terminal/main.mjs +83 -4
- package/src/terminal/repl-explore.mjs +1 -1
- package/src/terminal/repl-render.mjs +67 -12
- package/src/terminal/repl-state.mjs +4 -2
- package/src/terminal/repl.mjs +621 -99
- package/src/tools/agent.mjs +6 -2
- package/src/tools/analyze-image.mjs +1 -1
- package/src/tools/project-overview.mjs +7 -7
- package/src/tools/registry.mjs +88 -4
- package/src/ui/slash-commands.mjs +19 -1
- package/src/ui/sub-agent.mjs +14 -8
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin CLI Commands — open a workspace with a named plugin loaded.
|
|
3
|
+
*
|
|
4
|
+
* Commands:
|
|
5
|
+
* bahulam-code plugin <name> [path] Open workspace with plugin tools
|
|
6
|
+
*
|
|
7
|
+
* The named plugin is looked up in the standard plugin directories,
|
|
8
|
+
* verified to exist, then a local workspace is started at [path]
|
|
9
|
+
* (or the current directory) with the plugin's tools, handlers,
|
|
10
|
+
* and optionally sub-agents available.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as os from 'node:os';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import {
|
|
17
|
+
createLocalWorkspaceSession,
|
|
18
|
+
listLocalWorkspaceSessions,
|
|
19
|
+
loadLocalWorkspaceSession,
|
|
20
|
+
writeLocalWorkspaceSession,
|
|
21
|
+
} from '../local-service/session-store.mjs';
|
|
22
|
+
import { startLocalWorkspaceService } from '../local-service/server.mjs';
|
|
23
|
+
import { openLocalBrowser } from '../local-service/browser.mjs';
|
|
24
|
+
|
|
25
|
+
const RESET = '\x1b[0m';
|
|
26
|
+
const BOLD = '\x1b[1m';
|
|
27
|
+
const DIM = '\x1b[2m';
|
|
28
|
+
const CYAN = '\x1b[36m';
|
|
29
|
+
const GREEN = '\x1b[32m';
|
|
30
|
+
const YELLOW = '\x1b[33m';
|
|
31
|
+
const RED = '\x1b[31m';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Standard directories to search for plugins.
|
|
35
|
+
*/
|
|
36
|
+
const PLUGIN_SEARCH_DIRS = [
|
|
37
|
+
path.join(process.cwd(), '.bahulam', 'plugins'),
|
|
38
|
+
path.join(os.homedir(), '.bahulam', 'plugins'),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find a plugin directory by name across all standard search paths.
|
|
43
|
+
* Returns the directory path and manifests on success, null on miss.
|
|
44
|
+
*/
|
|
45
|
+
function findPluginDir(name) {
|
|
46
|
+
const needle = String(name || '').trim().toLowerCase();
|
|
47
|
+
if (!needle) return null;
|
|
48
|
+
|
|
49
|
+
for (const searchDir of PLUGIN_SEARCH_DIRS) {
|
|
50
|
+
try {
|
|
51
|
+
if (!fs.existsSync(searchDir)) continue;
|
|
52
|
+
const entries = fs.readdirSync(searchDir, { withFileTypes: true });
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!entry.isDirectory()) continue;
|
|
55
|
+
const pluginDir = path.join(searchDir, entry.name);
|
|
56
|
+
// Check both plugin.yaml and plugin.json
|
|
57
|
+
const yamlPath = path.join(pluginDir, 'plugin.yaml');
|
|
58
|
+
const jsonPath = path.join(pluginDir, 'plugin.json');
|
|
59
|
+
let manifestPath = null;
|
|
60
|
+
if (fs.existsSync(yamlPath)) manifestPath = yamlPath;
|
|
61
|
+
else if (fs.existsSync(jsonPath)) manifestPath = jsonPath;
|
|
62
|
+
|
|
63
|
+
if (!manifestPath) continue;
|
|
64
|
+
|
|
65
|
+
// Quick name match — match against directory name first (fast path),
|
|
66
|
+
// then parse the manifest for its metadata.name if needed
|
|
67
|
+
if (entry.name.toLowerCase() === needle) {
|
|
68
|
+
return { dir: pluginDir, manifestPath, searchDir };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Parse manifest to check metadata.name
|
|
72
|
+
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
73
|
+
let manifest;
|
|
74
|
+
try {
|
|
75
|
+
manifest = JSON.parse(raw);
|
|
76
|
+
} catch {
|
|
77
|
+
// Might be YAML — try simple YAML top-level name extraction
|
|
78
|
+
// For speed, check for `name:` line patterns
|
|
79
|
+
const nameMatch = raw.match(/^(?:name|metadata\.name|metadata:\s*\n\s+name)\s*:\s*(.+)$/m);
|
|
80
|
+
if (nameMatch) {
|
|
81
|
+
const metaName = nameMatch[1].trim().replace(/^["']|["']$/g, '').toLowerCase();
|
|
82
|
+
if (metaName === needle) {
|
|
83
|
+
return { dir: pluginDir, manifestPath, searchDir };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const metaName = (
|
|
90
|
+
manifest?.metadata?.name ||
|
|
91
|
+
manifest?.name ||
|
|
92
|
+
''
|
|
93
|
+
).toLowerCase();
|
|
94
|
+
if (metaName === needle) {
|
|
95
|
+
return { dir: pluginDir, manifestPath, searchDir };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Skip unreadable directories
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Main entry point for `bahulam-code plugin` subcommand.
|
|
107
|
+
* @param {object} args - parsed CLI args
|
|
108
|
+
* @param {object} [options]
|
|
109
|
+
* @param {string} [options.cwd]
|
|
110
|
+
*/
|
|
111
|
+
export async function handlePluginCommand(args, { cwd = process.cwd() } = {}) {
|
|
112
|
+
const pluginName = String(args.pluginName || '').trim();
|
|
113
|
+
const targetPath = String(args.targetPath || cwd).trim();
|
|
114
|
+
|
|
115
|
+
if (!pluginName || args.help) {
|
|
116
|
+
printPluginUsage();
|
|
117
|
+
process.exit(args.help ? 0 : 1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 1. Find the plugin
|
|
121
|
+
const found = findPluginDir(pluginName);
|
|
122
|
+
if (!found) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
`${RED}✗ Plugin "${pluginName}" not found.${RESET}\n` +
|
|
125
|
+
` ${DIM}Searched:${RESET}\n` +
|
|
126
|
+
PLUGIN_SEARCH_DIRS.map(d => ` ${d}`).join('\n') + '\n' +
|
|
127
|
+
` ${DIM}Create a plugin.yaml or plugin.json in one of these directories.${RESET}\n`
|
|
128
|
+
);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Verify the target path exists
|
|
133
|
+
let resolvedPath;
|
|
134
|
+
try {
|
|
135
|
+
resolvedPath = path.resolve(cwd, targetPath);
|
|
136
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
137
|
+
process.stderr.write(
|
|
138
|
+
`${RED}✗ Target path does not exist: ${resolvedPath}${RESET}\n`
|
|
139
|
+
);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
process.stderr.write(
|
|
144
|
+
`${RED}✗ Invalid target path: ${err.message}${RESET}\n`
|
|
145
|
+
);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. Create a local workspace session with plugin context
|
|
150
|
+
const sessionTitle = `${pluginName} plugin — ${path.basename(resolvedPath) || resolvedPath}`;
|
|
151
|
+
const { session, token } = createLocalWorkspaceSession({
|
|
152
|
+
targetPath: resolvedPath,
|
|
153
|
+
cwd,
|
|
154
|
+
kind: `plugin-${pluginName}`,
|
|
155
|
+
title: sessionTitle,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Augment session with plugin metadata so the workspace knows which
|
|
159
|
+
// plugin to highlight
|
|
160
|
+
const stored = loadLocalWorkspaceSession(session.id);
|
|
161
|
+
if (stored) {
|
|
162
|
+
stored.plugin = {
|
|
163
|
+
name: pluginName,
|
|
164
|
+
plugin_dir: found.dir,
|
|
165
|
+
manifest_path: found.manifestPath,
|
|
166
|
+
};
|
|
167
|
+
writeLocalWorkspaceSession(stored);
|
|
168
|
+
session.plugin = stored.plugin;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 4. Start the workspace service
|
|
172
|
+
const service = await startLocalWorkspaceService({
|
|
173
|
+
session,
|
|
174
|
+
token,
|
|
175
|
+
port: args.port || 0,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// 5. Output
|
|
179
|
+
const url = typeof service === 'object' ? service.url : '';
|
|
180
|
+
if (args.json) {
|
|
181
|
+
process.stdout.write(
|
|
182
|
+
`${JSON.stringify({ ok: true, session, plugin: pluginName, url, port: service.port }, null, 2)}\n`
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
process.stderr.write(`\n${BOLD}${CYAN}Bahulam Plugin Workspace${RESET}\n`);
|
|
186
|
+
process.stderr.write(` ${DIM}plugin${RESET} ${pluginName}\n`);
|
|
187
|
+
process.stderr.write(` ${DIM}session${RESET} ${session.id}\n`);
|
|
188
|
+
process.stderr.write(` ${DIM}root${RESET} ${session.root_path}\n`);
|
|
189
|
+
process.stderr.write(` ${DIM}url${RESET} ${CYAN}${url}${RESET}\n\n`);
|
|
190
|
+
process.stderr.write(
|
|
191
|
+
`${GREEN}ready${RESET} ${DIM}Plugin workspace started at 127.0.0.1:${service.port}. Press Ctrl+C to stop.${RESET}\n`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (args.open !== false) {
|
|
196
|
+
openLocalBrowser(url);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 6. Wait for shutdown
|
|
200
|
+
await new Promise((resolve) => {
|
|
201
|
+
let done = false;
|
|
202
|
+
const stop = async () => {
|
|
203
|
+
if (done) return;
|
|
204
|
+
done = true;
|
|
205
|
+
await service.close();
|
|
206
|
+
resolve();
|
|
207
|
+
};
|
|
208
|
+
process.once('SIGINT', stop);
|
|
209
|
+
process.once('SIGTERM', stop);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function printPluginUsage() {
|
|
214
|
+
process.stderr.write(
|
|
215
|
+
`${BOLD}PLUGIN COMMANDS${RESET}\n` +
|
|
216
|
+
` ${CYAN}bahulam plugin <name> [path]${RESET} Open a workspace with a plugin loaded\n` +
|
|
217
|
+
` ${CYAN}bahulam plugin install <src>${RESET} Install from git URL, tarball, local dir, or registry name\n` +
|
|
218
|
+
` ${CYAN}bahulam plugin validate <path|name>${RESET} Preflight without installing (schema + handlers + collisions)\n` +
|
|
219
|
+
` ${CYAN}bahulam plugin list${RESET} List installed plugins\n` +
|
|
220
|
+
` ${CYAN}bahulam plugin info <name>${RESET} Show manifest details\n` +
|
|
221
|
+
` ${CYAN}bahulam plugin remove <name>${RESET} Uninstall a plugin\n` +
|
|
222
|
+
` ${CYAN}bahulam plugin enable|disable <name>${RESET} Toggle without deleting\n` +
|
|
223
|
+
` ${CYAN}bahulam plugin update <name>${RESET} git pull the latest for git-installed plugins\n` +
|
|
224
|
+
`\n` +
|
|
225
|
+
` ${DIM}Install source shapes:${RESET}\n` +
|
|
226
|
+
` git URL https://github.com/foo/bar.git\n` +
|
|
227
|
+
` tarball URL https://.../bar-1.0.0.tgz\n` +
|
|
228
|
+
` local directory ./my-plugin or /abs/path\n` +
|
|
229
|
+
` registry name seo-toolkit (looked up in awesome-bahulam-plugins)\n` +
|
|
230
|
+
`\n` +
|
|
231
|
+
` ${DIM}Options:${RESET}\n` +
|
|
232
|
+
` --port <n> (open) bind a specific localhost port\n` +
|
|
233
|
+
` --no-open (open) start service without opening the browser\n` +
|
|
234
|
+
` --json print machine-readable JSON\n` +
|
|
235
|
+
` --project install into <cwd>/.bahulam/plugins instead of ~/.bahulam/plugins\n` +
|
|
236
|
+
` --global install into ~/.bahulam/plugins (default)\n` +
|
|
237
|
+
` --ref <tag|branch> (install/update) pin a git ref\n` +
|
|
238
|
+
` --force overwrite existing install\n` +
|
|
239
|
+
`\n` +
|
|
240
|
+
` ${DIM}Search paths (later overrides earlier):${RESET}\n` +
|
|
241
|
+
PLUGIN_SEARCH_DIRS.map(d => ` ${d}`).join('\n') + '\n' +
|
|
242
|
+
`\n` +
|
|
243
|
+
` ${DIM}Example:${RESET}\n` +
|
|
244
|
+
` bahulam plugin install https://github.com/community/seo-toolkit\n` +
|
|
245
|
+
` bahulam plugin seo-toolkit ~/projects/client-site\n`
|
|
246
|
+
);
|
|
247
|
+
}
|
package/src/config/cli-args.mjs
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* --max-turns Maximum conversation turns
|
|
12
12
|
* --allowedTools Comma-separated allowed tools
|
|
13
13
|
* --disallowedTools Comma-separated denied tools
|
|
14
|
+
* --agent <slug> Run a named agent (local deterministic graph)
|
|
15
|
+
* --workflow <name> Run a named workflow (local deterministic graph)
|
|
14
16
|
* --verbose, -v Verbose output
|
|
15
17
|
* --debug, -d Debug mode
|
|
16
18
|
* --version Show version
|
|
@@ -34,6 +36,8 @@ export function parseArgs(args) {
|
|
|
34
36
|
resumeSessionId: null,
|
|
35
37
|
headless: false,
|
|
36
38
|
skipPermissions: false,
|
|
39
|
+
agent: null,
|
|
40
|
+
workflow: null,
|
|
37
41
|
vision: [],
|
|
38
42
|
verbose: false,
|
|
39
43
|
debug: false,
|
|
@@ -109,6 +113,14 @@ export function parseArgs(args) {
|
|
|
109
113
|
result.skipPermissions = true; // headless implies skip permissions
|
|
110
114
|
break;
|
|
111
115
|
|
|
116
|
+
case '--agent':
|
|
117
|
+
result.agent = args[++i];
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case '--workflow':
|
|
121
|
+
result.workflow = args[++i];
|
|
122
|
+
break;
|
|
123
|
+
|
|
112
124
|
case '--cache-report':
|
|
113
125
|
// PRD-071 §1.5 — write a machine-readable cache summary to
|
|
114
126
|
// <path> at end of run. Consumed by benchmark/cache-check.sh.
|
|
@@ -184,6 +196,8 @@ Options:
|
|
|
184
196
|
--disallowedTools <tools> Comma-separated list of denied tools
|
|
185
197
|
--resume, -r [sessionId] Resume last session (or specific session)
|
|
186
198
|
--continue Alias for --resume
|
|
199
|
+
--agent <slug> Run a named agent as a deterministic local graph
|
|
200
|
+
--workflow <name> Run a named workflow as a deterministic local graph
|
|
187
201
|
--headless Non-interactive mode: auto-approve, JSONL output
|
|
188
202
|
--cache-report <file> Write prompt-cache summary JSON to <file> (headless only)
|
|
189
203
|
--vision <image-path> Attach image path in headless mode
|
|
@@ -197,6 +211,8 @@ Examples:
|
|
|
197
211
|
occ Start interactive REPL
|
|
198
212
|
occ -p "What is 2+2?" Run prompt and exit
|
|
199
213
|
occ -m claude-haiku-4-5 Use Haiku model
|
|
214
|
+
occ --agent explore -p "Map auth flow" Run the explore agent headlessly
|
|
215
|
+
occ --workflow deploy -p "Deploy" Run a workflow headlessly
|
|
200
216
|
occ --debug -p "Fix bug" Debug mode with prompt
|
|
201
217
|
`.trim();
|
|
202
218
|
}
|
package/src/config/env.mjs
CHANGED
|
@@ -116,6 +116,8 @@ export const ENV_SCHEMA = {
|
|
|
116
116
|
// Extended: Plugins
|
|
117
117
|
CLAUDE_CODE_PLUGIN_DIR: { type: 'string', description: 'Custom plugin directory' },
|
|
118
118
|
CLAUDE_CODE_DISABLE_PLUGINS: { type: 'boolean', default: false, description: 'Disable all plugins' },
|
|
119
|
+
BAHULAM_PLUGIN_DIR: { type: 'string', description: 'Bahulam plugin directory (comma-separated paths)' },
|
|
120
|
+
BAHULAM_DISABLE_PLUGINS: { type: 'string', description: 'Comma-separated plugin names to disable' },
|
|
119
121
|
|
|
120
122
|
// Extended: Session
|
|
121
123
|
CLAUDE_CODE_SESSION_TTL: { type: 'number', default: 86400000, description: 'Session TTL in ms (default 24h)' },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { loadBahulamSettings } from './settings-loader.mjs';
|
|
4
4
|
|
|
5
5
|
function asArray(value) {
|
|
6
6
|
if (!value) return [];
|
|
@@ -46,11 +46,11 @@ export class HookRunner {
|
|
|
46
46
|
constructor({ cwd = process.cwd(), settings = null, sessionId = null } = {}) {
|
|
47
47
|
this.cwd = cwd;
|
|
48
48
|
this.sessionId = sessionId;
|
|
49
|
-
this.settings = settings ||
|
|
49
|
+
this.settings = settings || loadBahulamSettings({ cwd }).settings;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
reload() {
|
|
53
|
-
this.settings =
|
|
53
|
+
this.settings = loadBahulamSettings({ cwd: this.cwd }).settings;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
hooksFor(event) {
|
|
@@ -74,11 +74,11 @@ export class HookRunner {
|
|
|
74
74
|
const env = {
|
|
75
75
|
...process.env,
|
|
76
76
|
...(this.settings?.env || {}),
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
BAHULAM_TOOL_NAME: toolName,
|
|
78
|
+
BAHULAM_TOOL_INPUT_FILE_PATH: input.tool_input.file_path || input.tool_input.path || '',
|
|
79
|
+
BAHULAM_PROJECT_DIR: this.cwd,
|
|
80
|
+
BAHULAM_SESSION_ID: this.sessionId || '',
|
|
81
|
+
BAHULAM_TURN_ID: payload.turnId || '',
|
|
82
82
|
};
|
|
83
83
|
const result = await runCommand(hook.command, {
|
|
84
84
|
cwd: path.resolve(this.cwd),
|
|
@@ -17,15 +17,19 @@ function readIfExists(filePath, maxChars = 12000) {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function readMemoryFile(dir, maxChars) {
|
|
21
|
+
return readIfExists(path.join(dir, 'BAHULAM.md'), maxChars);
|
|
22
|
+
}
|
|
23
|
+
|
|
20
24
|
export function loadBahulamMemory({ cwd = process.cwd() } = {}) {
|
|
21
25
|
const files = [];
|
|
22
|
-
const global =
|
|
26
|
+
const global = readMemoryFile(bahulamHome());
|
|
23
27
|
if (global) files.push({ source: 'global', ...global });
|
|
24
28
|
|
|
25
|
-
const topLevel =
|
|
29
|
+
const topLevel = readMemoryFile(cwd);
|
|
26
30
|
if (topLevel) files.push({ source: 'project-top-level', ...topLevel });
|
|
27
31
|
|
|
28
|
-
const project =
|
|
32
|
+
const project = readMemoryFile(projectConfigDir(cwd));
|
|
29
33
|
if (project) files.push({ source: 'project', ...project });
|
|
30
34
|
|
|
31
35
|
return files;
|
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { deepMerge } from '../core/policy-resolver.mjs';
|
|
4
4
|
|
|
5
|
-
export const
|
|
5
|
+
export const DEFAULT_BAHULAM_SETTINGS = Object.freeze({
|
|
6
6
|
env: {},
|
|
7
7
|
permissions: {
|
|
8
8
|
shellAllowlist: [],
|
|
@@ -16,6 +16,7 @@ export const DEFAULT_KEPLER_SETTINGS = Object.freeze({
|
|
|
16
16
|
},
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
|
|
19
20
|
function readJson(filePath) {
|
|
20
21
|
try {
|
|
21
22
|
if (!fs.existsSync(filePath)) return null;
|
|
@@ -25,10 +26,10 @@ function readJson(filePath) {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export function
|
|
29
|
+
export function loadBahulamSettings({ cwd = process.cwd() } = {}) {
|
|
29
30
|
const base = path.join(cwd, '.bahulam');
|
|
30
31
|
const layers = [
|
|
31
|
-
{ name: 'default', path: null, data:
|
|
32
|
+
{ name: 'default', path: null, data: DEFAULT_BAHULAM_SETTINGS },
|
|
32
33
|
];
|
|
33
34
|
for (const [name, file] of [
|
|
34
35
|
['project', path.join(base, 'settings.json')],
|
|
@@ -43,3 +44,4 @@ export function loadKeplerSettings({ cwd = process.cwd() } = {}) {
|
|
|
43
44
|
for (const layer of layers) settings = deepMerge(settings, layer.data || {});
|
|
44
45
|
return { settings, layers };
|
|
45
46
|
}
|
|
47
|
+
|
package/src/core/attachments.mjs
CHANGED
|
@@ -419,8 +419,8 @@ export function loadImageAttachment(filePath, { cwd = process.cwd(), maxBytes =
|
|
|
419
419
|
export function prepareImageAttachments(input, {
|
|
420
420
|
cwd = process.cwd(),
|
|
421
421
|
extraPaths = [],
|
|
422
|
-
maxImageBytes = envInt('
|
|
423
|
-
maxTurnBytes = envInt('
|
|
422
|
+
maxImageBytes = envInt('BAHULAM_VISION_MAX_IMAGE_BYTES', DEFAULT_MAX_IMAGE_BYTES),
|
|
423
|
+
maxTurnBytes = envInt('BAHULAM_VISION_MAX_TURN_BYTES', DEFAULT_MAX_TURN_BYTES),
|
|
424
424
|
} = {}) {
|
|
425
425
|
const parsed = parseImageReferences(input, { cwd });
|
|
426
426
|
const paths = [
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BackgroundTasks — the one registry for long-running processes the agent
|
|
3
|
+
* starts (docker build, npm run dev, test suites). Jobs get a run id,
|
|
4
|
+
* a per-job timeout with SIGTERM→SIGKILL escalation, output spooled to
|
|
5
|
+
* .bahulam/tmp/jobs/<id>.log (with a bounded in-memory tail), completion
|
|
6
|
+
* listeners for wake-on-finish delivery, and best-effort cleanup of the
|
|
7
|
+
* whole process group when the CLI exits.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
14
|
+
const KILL_ESCALATION_MS = 5000;
|
|
15
|
+
const MAX_TAIL_BYTES = 64 * 1024;
|
|
16
|
+
|
|
17
|
+
function stripAnsi(str) {
|
|
18
|
+
// eslint-disable-next-line no-control-regex
|
|
19
|
+
return String(str || '').replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class BackgroundTasks {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.jobs = new Map();
|
|
25
|
+
this._seq = 0;
|
|
26
|
+
this._listeners = new Set();
|
|
27
|
+
this._exitHookInstalled = false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onExit(listener) {
|
|
31
|
+
this._listeners.add(listener);
|
|
32
|
+
return () => this._listeners.delete(listener);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
start({ command, cwd = process.cwd(), timeoutMs = DEFAULT_TIMEOUT_MS, name = '', on_complete = null }) {
|
|
36
|
+
this._installExitHook();
|
|
37
|
+
const id = `job-${++this._seq}-${Date.now().toString(36)}`;
|
|
38
|
+
const logDir = path.join(cwd, '.bahulam', 'tmp', 'jobs');
|
|
39
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
40
|
+
const logPath = path.join(logDir, `${id}.log`);
|
|
41
|
+
const logStream = fs.createWriteStream(logPath);
|
|
42
|
+
|
|
43
|
+
const proc = spawn('bash', ['-c', command], {
|
|
44
|
+
cwd,
|
|
45
|
+
env: { ...process.env },
|
|
46
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
47
|
+
detached: process.platform !== 'win32',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const job = {
|
|
51
|
+
id,
|
|
52
|
+
name: name || command.slice(0, 60),
|
|
53
|
+
command,
|
|
54
|
+
cwd,
|
|
55
|
+
pid: proc.pid,
|
|
56
|
+
status: 'running',
|
|
57
|
+
exit_code: null,
|
|
58
|
+
started_at: Date.now(),
|
|
59
|
+
ended_at: null,
|
|
60
|
+
log_path: logPath,
|
|
61
|
+
tail: '',
|
|
62
|
+
timed_out: false,
|
|
63
|
+
on_complete,
|
|
64
|
+
_proc: proc,
|
|
65
|
+
_done: null,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const appendTail = (chunk) => {
|
|
69
|
+
const next = job.tail + chunk.toString();
|
|
70
|
+
job.tail = next.length > MAX_TAIL_BYTES ? next.slice(next.length - MAX_TAIL_BYTES) : next;
|
|
71
|
+
};
|
|
72
|
+
proc.stdout.on('data', (d) => { logStream.write(d); appendTail(d); });
|
|
73
|
+
proc.stderr.on('data', (d) => { logStream.write(d); appendTail(d); });
|
|
74
|
+
|
|
75
|
+
let killTimer = null;
|
|
76
|
+
const timer = timeoutMs > 0 ? setTimeout(() => {
|
|
77
|
+
job.timed_out = true;
|
|
78
|
+
this._kill(job, 'SIGTERM');
|
|
79
|
+
killTimer = setTimeout(() => this._kill(job, 'SIGKILL'), KILL_ESCALATION_MS);
|
|
80
|
+
}, timeoutMs) : null;
|
|
81
|
+
if (timer?.unref) timer.unref();
|
|
82
|
+
|
|
83
|
+
job._done = new Promise((resolve) => {
|
|
84
|
+
proc.on('close', (code) => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
clearTimeout(killTimer);
|
|
87
|
+
job.exit_code = code;
|
|
88
|
+
job.ended_at = Date.now();
|
|
89
|
+
job.status = job.timed_out ? 'timeout'
|
|
90
|
+
: job.status === 'killed' ? 'killed'
|
|
91
|
+
: code === 0 ? 'completed' : 'failed';
|
|
92
|
+
job.tail = stripAnsi(job.tail);
|
|
93
|
+
logStream.end();
|
|
94
|
+
for (const listener of this._listeners) {
|
|
95
|
+
try { listener(this.describe(job.id)); } catch { /* listeners are best-effort */ }
|
|
96
|
+
}
|
|
97
|
+
resolve(this.describe(job.id));
|
|
98
|
+
});
|
|
99
|
+
proc.on('error', (err) => {
|
|
100
|
+
job.status = 'failed';
|
|
101
|
+
job.tail = `${job.tail}\n${err.message}`.trim();
|
|
102
|
+
job.ended_at = Date.now();
|
|
103
|
+
logStream.end();
|
|
104
|
+
resolve(this.describe(job.id));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
proc.unref();
|
|
109
|
+
this.jobs.set(id, job);
|
|
110
|
+
return this.describe(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Await a job's completion; resolves with its final description. */
|
|
114
|
+
wait(id) {
|
|
115
|
+
const job = this.jobs.get(id);
|
|
116
|
+
if (!job) return Promise.resolve(null);
|
|
117
|
+
if (job.status !== 'running') return Promise.resolve(this.describe(id));
|
|
118
|
+
// Background jobs are unref'd so fire-and-forget tasks do not pin the CLI
|
|
119
|
+
// open. When a caller explicitly awaits wait(id), temporarily ref the
|
|
120
|
+
// process so fast commands still get their close event before Node decides
|
|
121
|
+
// the top-level await is unsettled.
|
|
122
|
+
try { job._proc?.ref?.(); } catch { /* best effort */ }
|
|
123
|
+
return job._done.finally(() => {
|
|
124
|
+
try { job._proc?.unref?.(); } catch { /* best effort */ }
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe(id) {
|
|
129
|
+
const job = this.jobs.get(id);
|
|
130
|
+
if (!job) return null;
|
|
131
|
+
return {
|
|
132
|
+
id: job.id,
|
|
133
|
+
name: job.name,
|
|
134
|
+
command: job.command,
|
|
135
|
+
pid: job.pid,
|
|
136
|
+
status: job.status,
|
|
137
|
+
exit_code: job.exit_code,
|
|
138
|
+
duration_s: Math.round(((job.ended_at || Date.now()) - job.started_at) / 1000),
|
|
139
|
+
log_path: job.log_path,
|
|
140
|
+
tail: job.tail,
|
|
141
|
+
timed_out: job.timed_out,
|
|
142
|
+
on_complete: job.on_complete || null,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
list() {
|
|
147
|
+
return [...this.jobs.keys()].map(id => {
|
|
148
|
+
const d = this.describe(id);
|
|
149
|
+
return { ...d, tail: undefined };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
kill(id) {
|
|
154
|
+
const job = this.jobs.get(id);
|
|
155
|
+
if (!job) return null;
|
|
156
|
+
if (job.status === 'running') {
|
|
157
|
+
job.status = 'killed';
|
|
158
|
+
this._kill(job, 'SIGTERM');
|
|
159
|
+
setTimeout(() => this._kill(job, 'SIGKILL'), KILL_ESCALATION_MS)?.unref?.();
|
|
160
|
+
}
|
|
161
|
+
return this.describe(id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_kill(job, signal) {
|
|
165
|
+
if (!job?._proc?.pid) return;
|
|
166
|
+
try {
|
|
167
|
+
if (process.platform !== 'win32') {
|
|
168
|
+
process.kill(-job._proc.pid, signal);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
} catch { /* fall through */ }
|
|
172
|
+
try { job._proc.kill(signal); } catch { /* already exited */ }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_installExitHook() {
|
|
176
|
+
if (this._exitHookInstalled) return;
|
|
177
|
+
this._exitHookInstalled = true;
|
|
178
|
+
process.on('exit', () => {
|
|
179
|
+
for (const job of this.jobs.values()) {
|
|
180
|
+
if (job.status === 'running') this._kill(job, 'SIGKILL');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const backgroundTasks = new BackgroundTasks();
|