@zhijiewang/openharness 2.19.0 → 2.21.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.
- package/README.md +21 -1
- package/README.zh-CN.md +21 -1
- package/dist/commands/ai.js +10 -0
- package/dist/commands/index.d.ts +23 -0
- package/dist/commands/index.js +64 -0
- package/dist/commands/info.js +46 -3
- package/dist/commands/session.d.ts +18 -1
- package/dist/commands/session.js +82 -2
- package/dist/commands/settings.d.ts +1 -1
- package/dist/commands/settings.js +71 -1
- package/dist/harness/config.d.ts +25 -0
- package/dist/harness/hooks.d.ts +34 -1
- package/dist/harness/hooks.js +27 -0
- package/dist/harness/rules.js +18 -2
- package/dist/harness/submit-handler.js +14 -1
- package/dist/main.js +186 -32
- package/dist/mcp/client.d.ts +23 -0
- package/dist/mcp/client.js +37 -0
- package/dist/mcp/loader.d.ts +49 -2
- package/dist/mcp/loader.js +86 -3
- package/dist/query/tools.js +34 -6
- package/dist/tools/EnterWorktreeTool/index.js +4 -0
- package/dist/tools/ExitWorktreeTool/index.js +7 -0
- package/dist/tools/TaskCreateTool/index.js +5 -0
- package/dist/tools/TaskUpdateTool/index.js +11 -0
- package/dist/utils/debug.d.ts +63 -0
- package/dist/utils/debug.js +122 -0
- package/package.json +1 -1
package/dist/harness/hooks.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* - prompt: LLM yes/no check via provider.complete()
|
|
11
11
|
*/
|
|
12
12
|
import { spawn, spawnSync } from "node:child_process";
|
|
13
|
+
import { debug } from "../utils/debug.js";
|
|
13
14
|
import { readOhConfig } from "./config.js";
|
|
14
15
|
let cachedHooks;
|
|
15
16
|
export function getHooks() {
|
|
@@ -22,6 +23,18 @@ export function getHooks() {
|
|
|
22
23
|
/** Clear hook cache (call after config changes) */
|
|
23
24
|
export function invalidateHookCache() {
|
|
24
25
|
cachedHooks = undefined;
|
|
26
|
+
cachedDisableAllHooks = undefined;
|
|
27
|
+
}
|
|
28
|
+
let cachedDisableAllHooks;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the configured `disableAllHooks` kill switch is set.
|
|
31
|
+
* Cached so the per-emit cost is a single boolean read.
|
|
32
|
+
*/
|
|
33
|
+
export function areHooksEnabled() {
|
|
34
|
+
if (cachedDisableAllHooks === undefined) {
|
|
35
|
+
cachedDisableAllHooks = readOhConfig()?.disableAllHooks === true;
|
|
36
|
+
}
|
|
37
|
+
return !cachedDisableAllHooks;
|
|
25
38
|
}
|
|
26
39
|
function buildEnv(event, ctx) {
|
|
27
40
|
const env = {
|
|
@@ -71,6 +84,12 @@ function buildEnv(event, ctx) {
|
|
|
71
84
|
env.OH_TURN_NUMBER = ctx.turnNumber;
|
|
72
85
|
if (ctx.turnReason !== undefined)
|
|
73
86
|
env.OH_TURN_REASON = ctx.turnReason;
|
|
87
|
+
if (ctx.worktreePath !== undefined)
|
|
88
|
+
env.OH_WORKTREE_PATH = ctx.worktreePath;
|
|
89
|
+
if (ctx.worktreeParent !== undefined)
|
|
90
|
+
env.OH_WORKTREE_PARENT = ctx.worktreeParent;
|
|
91
|
+
if (ctx.worktreeForced !== undefined)
|
|
92
|
+
env.OH_WORKTREE_FORCED = ctx.worktreeForced;
|
|
74
93
|
return env;
|
|
75
94
|
}
|
|
76
95
|
/**
|
|
@@ -400,10 +419,14 @@ async function executeHookDef(def, event, ctx) {
|
|
|
400
419
|
* All other hooks run asynchronously to avoid blocking the event loop.
|
|
401
420
|
*/
|
|
402
421
|
export function emitHook(event, ctx = {}) {
|
|
422
|
+
if (!areHooksEnabled())
|
|
423
|
+
return true;
|
|
403
424
|
const hooks = getHooks();
|
|
404
425
|
if (!hooks)
|
|
405
426
|
return true;
|
|
406
427
|
const defs = hooks[event] ?? [];
|
|
428
|
+
if (defs.length > 0)
|
|
429
|
+
debug("hooks", "fire", { event, count: defs.length, tool: ctx.toolName });
|
|
407
430
|
const env = buildEnv(event, ctx);
|
|
408
431
|
if (event === "preToolUse") {
|
|
409
432
|
// preToolUse command hooks must be synchronous — they gate tool execution
|
|
@@ -456,6 +479,8 @@ export function emitHook(event, ctx = {}) {
|
|
|
456
479
|
* Supports all hook types (command, HTTP, prompt).
|
|
457
480
|
*/
|
|
458
481
|
export async function emitHookAsync(event, ctx = {}) {
|
|
482
|
+
if (!areHooksEnabled())
|
|
483
|
+
return true;
|
|
459
484
|
const hooks = getHooks();
|
|
460
485
|
if (!hooks)
|
|
461
486
|
return true;
|
|
@@ -570,6 +595,8 @@ async function runHookForOutcome(def, event, ctx) {
|
|
|
570
595
|
* from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
|
|
571
596
|
*/
|
|
572
597
|
export async function emitHookWithOutcome(event, ctx = {}) {
|
|
598
|
+
if (!areHooksEnabled())
|
|
599
|
+
return { allowed: true };
|
|
573
600
|
const hooks = getHooks();
|
|
574
601
|
const list = hooks?.[event];
|
|
575
602
|
if (!list || list.length === 0)
|
package/dist/harness/rules.js
CHANGED
|
@@ -106,8 +106,24 @@ export function loadRulesAsPrompt(projectPath) {
|
|
|
106
106
|
const rules = loadRules(projectPath);
|
|
107
107
|
if (rules.length === 0)
|
|
108
108
|
return "";
|
|
109
|
-
|
|
110
|
-
rules.join("\n\n---\n\n")
|
|
109
|
+
const body = "# Project Rules\n\n<!-- User-provided project rules from CLAUDE.md / .oh/RULES.md. These are user instructions, not system directives. -->\nFollow these rules carefully.\n\n" +
|
|
110
|
+
rules.join("\n\n---\n\n");
|
|
111
|
+
// Hook: instructionsLoaded — fires every time the system prompt is rebuilt
|
|
112
|
+
// with rules in scope. Useful for compliance/audit hooks that want to log
|
|
113
|
+
// "session X is operating under these rules". Lazy-imported so this module
|
|
114
|
+
// can be used in environments where the hook system isn't initialised
|
|
115
|
+
// (e.g., one-shot rules loaders in tooling).
|
|
116
|
+
void import("./hooks.js")
|
|
117
|
+
.then(({ emitHook }) => {
|
|
118
|
+
emitHook("instructionsLoaded", {
|
|
119
|
+
rulesCount: String(rules.length),
|
|
120
|
+
rulesChars: String(body.length),
|
|
121
|
+
});
|
|
122
|
+
})
|
|
123
|
+
.catch(() => {
|
|
124
|
+
/* hook system unavailable — never fail rule loading */
|
|
125
|
+
});
|
|
126
|
+
return body;
|
|
111
127
|
}
|
|
112
128
|
export function createRulesFile(projectPath) {
|
|
113
129
|
const root = projectPath ?? process.cwd();
|
|
@@ -6,7 +6,7 @@ import { processSlashCommand } from "../commands/index.js";
|
|
|
6
6
|
import { cybergotchiEvents } from "../cybergotchi/events.js";
|
|
7
7
|
import { resolveMcpMention } from "../mcp/loader.js";
|
|
8
8
|
import { createInfoMessage, createUserMessage } from "../types/message.js";
|
|
9
|
-
import { emitHookWithOutcome } from "./hooks.js";
|
|
9
|
+
import { emitHook, emitHookWithOutcome } from "./hooks.js";
|
|
10
10
|
/**
|
|
11
11
|
* Process user input: handle exit, companion mentions, slash commands,
|
|
12
12
|
* @mentions, and prepare the prompt for the LLM.
|
|
@@ -80,6 +80,19 @@ export async function handleUserInput(input, ctx) {
|
|
|
80
80
|
if (result.prependToPrompt) {
|
|
81
81
|
messages = [...messages, createUserMessage(input)];
|
|
82
82
|
const prependPrompt = result.prependToPrompt;
|
|
83
|
+
// Slash command produced an expanded prompt — fire userPromptExpansion
|
|
84
|
+
// before userPromptSubmit so audit hooks can see the (input → expanded)
|
|
85
|
+
// boundary that's otherwise hidden from observers.
|
|
86
|
+
const slashCommand = trimmed.split(/\s/)[0] ?? trimmed;
|
|
87
|
+
emitHook("userPromptExpansion", {
|
|
88
|
+
slashCommand,
|
|
89
|
+
originalInput: input.slice(0, 1000),
|
|
90
|
+
prompt: prependPrompt.slice(0, 1000),
|
|
91
|
+
sessionId: ctx.sessionId,
|
|
92
|
+
model: ctx.currentModel,
|
|
93
|
+
provider: ctx.providerName,
|
|
94
|
+
permissionMode: ctx.permissionMode,
|
|
95
|
+
});
|
|
83
96
|
const prependOutcome = await emitHookWithOutcome("userPromptSubmit", {
|
|
84
97
|
prompt: prependPrompt,
|
|
85
98
|
sessionId: ctx.sessionId,
|
package/dist/main.js
CHANGED
|
@@ -23,9 +23,10 @@ import { detectProject, projectContextToPrompt } from "./harness/onboarding.js";
|
|
|
23
23
|
import { discoverSkills, skillsToPrompt } from "./harness/plugins.js";
|
|
24
24
|
import { createRulesFile, loadRules, loadRulesAsPrompt } from "./harness/rules.js";
|
|
25
25
|
import { listSessions } from "./harness/session.js";
|
|
26
|
-
import { connectedMcpServers, disconnectMcpClients, getMcpInstructions, loadMcpTools } from "./mcp/loader.js";
|
|
26
|
+
import { connectedMcpServers, disconnectMcpClients, getMcpInstructions, loadMcpPrompts, loadMcpTools, parseMcpConfigFile, } from "./mcp/loader.js";
|
|
27
27
|
import { loadOutputStyle } from "./outputStyles/index.js";
|
|
28
28
|
import { getAllTools } from "./tools.js";
|
|
29
|
+
import { configureDebug, debug } from "./utils/debug.js";
|
|
29
30
|
import { validateAgainstJsonSchema } from "./utils/json-schema.js";
|
|
30
31
|
import { parseMaxBudgetUsd } from "./utils/parse-budget.js";
|
|
31
32
|
const _require = createRequire(import.meta.url);
|
|
@@ -75,6 +76,40 @@ You have access to tools for reading, writing, and searching files, running shel
|
|
|
75
76
|
- When referencing code, include file_path:line_number.
|
|
76
77
|
- Do not restate what the user said. Do not add trailing summaries unless asked.
|
|
77
78
|
- Keep responses short and direct. If you can say it in one sentence, don't use three.`;
|
|
79
|
+
/**
|
|
80
|
+
* Read a system prompt from a file path, or exit 2 with a stderr message.
|
|
81
|
+
* Used by `--system-prompt-file` / `--append-system-prompt-file` so callers
|
|
82
|
+
* can keep prompts as version-controlled files instead of stuffing them on
|
|
83
|
+
* the command line. Trailing newline is stripped (most editors add one).
|
|
84
|
+
*/
|
|
85
|
+
function readSystemPromptFile(path, label) {
|
|
86
|
+
try {
|
|
87
|
+
return readFileSync(path, "utf8").replace(/\n$/, "");
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
91
|
+
process.stderr.write(`Error: ${label} '${path}' could not be read: ${message}\n`);
|
|
92
|
+
process.exit(2);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parse `--mcp-config <path>` (and the optional `--strict-mcp-config` flag)
|
|
97
|
+
* into a `LoadMcpOptions` shape ready to pass to `loadMcpTools`. Returns
|
|
98
|
+
* undefined when the user didn't pass `--mcp-config`. Exits 2 with a stderr
|
|
99
|
+
* message on parse / shape errors.
|
|
100
|
+
*/
|
|
101
|
+
function buildMcpLoadOpts(opts) {
|
|
102
|
+
if (!opts.mcpConfig)
|
|
103
|
+
return undefined;
|
|
104
|
+
try {
|
|
105
|
+
const extraServers = parseMcpConfigFile(opts.mcpConfig);
|
|
106
|
+
return { extraServers, strict: opts.strictMcpConfig === true };
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
110
|
+
process.exit(2);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
78
113
|
/**
|
|
79
114
|
* Parse the `--max-budget-usd` CLI argument into a positive USD amount, or
|
|
80
115
|
* exit 2 with an error message. The pure parser lives in
|
|
@@ -89,7 +124,19 @@ function parseMaxBudgetUsdOrExit(raw) {
|
|
|
89
124
|
}
|
|
90
125
|
return result.value;
|
|
91
126
|
}
|
|
92
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Build the assembled system prompt for a session.
|
|
129
|
+
*
|
|
130
|
+
* In `bare` mode (audit A4 — `--bare`) every optional contributor is skipped:
|
|
131
|
+
* no project context, no rules, no user profile, no remembered memories, no
|
|
132
|
+
* skill catalog, no MCP server instructions, no language directive, no output
|
|
133
|
+
* style. The result is exactly `DEFAULT_SYSTEM_PROMPT`. Used for fast SDK /
|
|
134
|
+
* CI invocations where the model just needs the tool-use baseline and the
|
|
135
|
+
* caller will supply its own context.
|
|
136
|
+
*/
|
|
137
|
+
function buildSystemPrompt(model, opts = {}) {
|
|
138
|
+
if (opts.bare)
|
|
139
|
+
return DEFAULT_SYSTEM_PROMPT;
|
|
93
140
|
const cfg = readOhConfig();
|
|
94
141
|
// Output-style preface (first — sets personality for everything that follows).
|
|
95
142
|
// Skipped silently for the "default" style (empty prompt).
|
|
@@ -146,13 +193,27 @@ program
|
|
|
146
193
|
.addOption(new Option("--output-format <format>", "Output format").choices(["json", "text", "stream-json"]).default("text"))
|
|
147
194
|
.option("--max-turns <n>", "Maximum turns", "20")
|
|
148
195
|
.option("--system-prompt <prompt>", "Override the system prompt")
|
|
196
|
+
.option("--system-prompt-file <path>", "Read the system prompt from a file (overrides --system-prompt)")
|
|
149
197
|
.option("--append-system-prompt <text>", "Append text to the system prompt")
|
|
198
|
+
.option("--append-system-prompt-file <path>", "Append the contents of a file to the system prompt")
|
|
150
199
|
.option("--allowed-tools <tools>", "Comma-separated list of allowed tools")
|
|
151
200
|
.option("--disallowed-tools <tools>", "Comma-separated list of disallowed tools")
|
|
152
201
|
.option("--resume <id>", "Resume a saved session (replays its message history before this prompt)")
|
|
153
202
|
.option("--setting-sources <sources>", "Comma-separated list of setting sources to merge (e.g. 'user,project,local'). Mirrors Claude Code's setting_sources.")
|
|
154
203
|
.option("--max-budget-usd <amount>", "Hard cap on session cost in USD. The agent halts with reason 'budget_exceeded' once totalCost reaches this amount. Mirrors Claude Code's --max-budget-usd.")
|
|
204
|
+
.option("--no-session-persistence", "Skip writing the session to disk under ~/.oh/sessions/. Useful for ephemeral CI runs that don't need resume.")
|
|
205
|
+
.option("--mcp-config <path>", 'Load MCP servers from a JSON file (in addition to .oh/config.yaml). File format: {"mcpServers": [...]} or a bare array.')
|
|
206
|
+
.option("--strict-mcp-config", "With --mcp-config, ignore .oh/config.yaml mcpServers — use only the file's servers.")
|
|
207
|
+
.option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline. Useful for fast CI / SDK invocations.")
|
|
208
|
+
.option("--debug [categories]", "Enable categorized debug logs to stderr. Pass comma-separated categories (e.g. 'mcp,hooks') or no value for all. Also reads OH_DEBUG.")
|
|
209
|
+
.option("--debug-file <path>", "When --debug is set, append debug lines to this file instead of stderr.")
|
|
155
210
|
.action(async (promptArg, opts) => {
|
|
211
|
+
configureDebug({
|
|
212
|
+
categories: opts.debug,
|
|
213
|
+
...(opts.debugFile ? { file: opts.debugFile } : {}),
|
|
214
|
+
});
|
|
215
|
+
const bare = opts.bare === true;
|
|
216
|
+
debug("startup", "oh run", { bare, model: opts.model });
|
|
156
217
|
// Read from stdin if prompt is "-" or omitted and stdin is not a TTY
|
|
157
218
|
let prompt;
|
|
158
219
|
if (!promptArg || promptArg === "-" || !process.stdin.isTTY) {
|
|
@@ -189,8 +250,14 @@ program
|
|
|
189
250
|
overrides.baseUrl = savedConfig.baseUrl;
|
|
190
251
|
const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
|
|
191
252
|
const { query } = await import("./query.js");
|
|
192
|
-
// Tool
|
|
193
|
-
|
|
253
|
+
// Tool list = built-ins + MCP server tools (project config + --mcp-config).
|
|
254
|
+
// Previously oh run skipped MCP entirely, which silently broke the SDK
|
|
255
|
+
// `tools=[...]` feature (the SDK injects mcpServers into a temp config but
|
|
256
|
+
// the CLI never read it back). `--bare` opts back out — built-ins only.
|
|
257
|
+
const mcpLoadOpts = buildMcpLoadOpts(opts);
|
|
258
|
+
const mcpTools = bare ? [] : await loadMcpTools(mcpLoadOpts);
|
|
259
|
+
debug("mcp", "loaded", { count: mcpTools.length, bare });
|
|
260
|
+
let tools = [...getAllTools(), ...mcpTools];
|
|
194
261
|
if (opts.allowedTools) {
|
|
195
262
|
const allowed = new Set(opts.allowedTools.split(",").map((s) => s.trim()));
|
|
196
263
|
tools = tools.filter((t) => allowed.has(t.name));
|
|
@@ -199,13 +266,22 @@ program
|
|
|
199
266
|
const disallowed = new Set(opts.disallowedTools.split(",").map((s) => s.trim()));
|
|
200
267
|
tools = tools.filter((t) => !disallowed.has(t.name));
|
|
201
268
|
}
|
|
202
|
-
|
|
269
|
+
process.on("exit", () => disconnectMcpClients());
|
|
270
|
+
// System prompt — file variants take precedence over inline string variants
|
|
271
|
+
// so callers can override-from-file without removing a stale --system-prompt
|
|
272
|
+
// they were previously passing.
|
|
203
273
|
let systemPrompt;
|
|
204
|
-
if (opts.
|
|
274
|
+
if (opts.systemPromptFile) {
|
|
275
|
+
systemPrompt = readSystemPromptFile(opts.systemPromptFile, "--system-prompt-file");
|
|
276
|
+
}
|
|
277
|
+
else if (opts.systemPrompt) {
|
|
205
278
|
systemPrompt = opts.systemPrompt;
|
|
206
279
|
}
|
|
207
280
|
else {
|
|
208
|
-
systemPrompt = buildSystemPrompt(model);
|
|
281
|
+
systemPrompt = buildSystemPrompt(model, { bare });
|
|
282
|
+
}
|
|
283
|
+
if (opts.appendSystemPromptFile) {
|
|
284
|
+
systemPrompt += `\n\n${readSystemPromptFile(opts.appendSystemPromptFile, "--append-system-prompt-file")}`;
|
|
209
285
|
}
|
|
210
286
|
if (opts.appendSystemPrompt) {
|
|
211
287
|
systemPrompt += `\n\n${opts.appendSystemPrompt}`;
|
|
@@ -233,6 +309,8 @@ program
|
|
|
233
309
|
// --resume <id> on a later run. Without this, every fresh `oh run` was
|
|
234
310
|
// a programmatic dead-end for resumption (issue #60).
|
|
235
311
|
const { createSession, loadSession, saveSession } = await import("./harness/session.js");
|
|
312
|
+
// Commander rewrites --no-session-persistence to opts.sessionPersistence === false.
|
|
313
|
+
const persistSession = opts.sessionPersistence !== false;
|
|
236
314
|
let priorMessages;
|
|
237
315
|
let sessionId;
|
|
238
316
|
let sessionRecord;
|
|
@@ -250,7 +328,8 @@ program
|
|
|
250
328
|
else {
|
|
251
329
|
sessionRecord = createSession(provider.name, model);
|
|
252
330
|
sessionId = sessionRecord.id;
|
|
253
|
-
|
|
331
|
+
if (persistSession)
|
|
332
|
+
saveSession(sessionRecord);
|
|
254
333
|
}
|
|
255
334
|
if (outputFormat === "stream-json") {
|
|
256
335
|
// Emit a session_start event so SDK callers can capture the id for
|
|
@@ -352,16 +431,18 @@ program
|
|
|
352
431
|
// they're per-tool ephemerals; the assistant's final text is what
|
|
353
432
|
// matters for context resumption. Mirrors the REPL's save-on-exit pattern
|
|
354
433
|
// (src/components/REPL.tsx:120) but at one-shot scope.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
434
|
+
if (persistSession) {
|
|
435
|
+
try {
|
|
436
|
+
const { createUserMessage, createAssistantMessage } = await import("./types/message.js");
|
|
437
|
+
const newMessages = [...(priorMessages ?? []), createUserMessage(prompt)];
|
|
438
|
+
if (fullOutput)
|
|
439
|
+
newMessages.push(createAssistantMessage(fullOutput));
|
|
440
|
+
sessionRecord.messages = newMessages;
|
|
441
|
+
saveSession(sessionRecord);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
/* persistence is best-effort — never fail the user's run on a save error */
|
|
445
|
+
}
|
|
365
446
|
}
|
|
366
447
|
});
|
|
367
448
|
// ── `oh session`: long-lived stateful session for the Python SDK ──
|
|
@@ -376,10 +457,25 @@ program
|
|
|
376
457
|
.option("--disallowed-tools <tools>", "Comma-separated disallowed tool names")
|
|
377
458
|
.option("--max-turns <n>", "Maximum turns per prompt", "20")
|
|
378
459
|
.option("--system-prompt <prompt>", "Override the system prompt")
|
|
460
|
+
.option("--system-prompt-file <path>", "Read the system prompt from a file (overrides --system-prompt)")
|
|
461
|
+
.option("--append-system-prompt <text>", "Append text to the system prompt")
|
|
462
|
+
.option("--append-system-prompt-file <path>", "Append the contents of a file to the system prompt")
|
|
379
463
|
.option("--resume <id>", "Resume a saved session (seeds the conversation with its prior message history)")
|
|
380
464
|
.option("--setting-sources <sources>", "Comma-separated list of setting sources to merge (mirrors Claude Code's setting_sources).")
|
|
381
465
|
.option("--max-budget-usd <amount>", "Hard cap on session cost in USD. Each prompt's cost accumulates; the agent halts with reason 'budget_exceeded' once totalCost reaches this amount.")
|
|
466
|
+
.option("--no-session-persistence", "Skip writing the session to disk under ~/.oh/sessions/. Useful for ephemeral SDK clients that don't need resume.")
|
|
467
|
+
.option("--mcp-config <path>", 'Load MCP servers from a JSON file (in addition to .oh/config.yaml). File format: {"mcpServers": [...]} or a bare array.')
|
|
468
|
+
.option("--strict-mcp-config", "With --mcp-config, ignore .oh/config.yaml mcpServers — use only the file's servers.")
|
|
469
|
+
.option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline.")
|
|
470
|
+
.option("--debug [categories]", "Enable categorized debug logs to stderr. Pass comma-separated categories (e.g. 'mcp,hooks') or no value for all. Also reads OH_DEBUG.")
|
|
471
|
+
.option("--debug-file <path>", "When --debug is set, append debug lines to this file instead of stderr.")
|
|
382
472
|
.action(async (opts) => {
|
|
473
|
+
configureDebug({
|
|
474
|
+
categories: opts.debug,
|
|
475
|
+
...(opts.debugFile ? { file: opts.debugFile } : {}),
|
|
476
|
+
});
|
|
477
|
+
const bare = opts.bare === true;
|
|
478
|
+
debug("startup", "oh session", { bare, model: opts.model });
|
|
383
479
|
const settingSources = parseSettingSources(opts.settingSources);
|
|
384
480
|
const savedConfig = readOhConfig(undefined, settingSources);
|
|
385
481
|
const permissionMode = (opts.permissionMode ??
|
|
@@ -395,7 +491,14 @@ program
|
|
|
395
491
|
const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
|
|
396
492
|
const { query } = await import("./query.js");
|
|
397
493
|
const { createAssistantMessage, createToolResultMessage, createUserMessage } = await import("./types/message.js");
|
|
398
|
-
|
|
494
|
+
// Tool list = built-ins + MCP server tools (project config + --mcp-config).
|
|
495
|
+
// Same fix as `oh run` — `oh session` previously skipped MCP entirely,
|
|
496
|
+
// which silently broke the SDK `tools=[...]` feature for stateful clients.
|
|
497
|
+
// `--bare` opts back out — built-ins only.
|
|
498
|
+
const mcpLoadOpts = buildMcpLoadOpts(opts);
|
|
499
|
+
const mcpTools = bare ? [] : await loadMcpTools(mcpLoadOpts);
|
|
500
|
+
debug("mcp", "loaded", { count: mcpTools.length, bare });
|
|
501
|
+
let tools = [...getAllTools(), ...mcpTools];
|
|
399
502
|
if (opts.allowedTools) {
|
|
400
503
|
const allowed = new Set(opts.allowedTools.split(",").map((s) => s.trim()));
|
|
401
504
|
tools = tools.filter((t) => allowed.has(t.name));
|
|
@@ -404,7 +507,23 @@ program
|
|
|
404
507
|
const disallowed = new Set(opts.disallowedTools.split(",").map((s) => s.trim()));
|
|
405
508
|
tools = tools.filter((t) => !disallowed.has(t.name));
|
|
406
509
|
}
|
|
407
|
-
|
|
510
|
+
process.on("exit", () => disconnectMcpClients());
|
|
511
|
+
let systemPrompt;
|
|
512
|
+
if (opts.systemPromptFile) {
|
|
513
|
+
systemPrompt = readSystemPromptFile(opts.systemPromptFile, "--system-prompt-file");
|
|
514
|
+
}
|
|
515
|
+
else if (opts.systemPrompt) {
|
|
516
|
+
systemPrompt = opts.systemPrompt;
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
systemPrompt = buildSystemPrompt(model, { bare });
|
|
520
|
+
}
|
|
521
|
+
if (opts.appendSystemPromptFile) {
|
|
522
|
+
systemPrompt += `\n\n${readSystemPromptFile(opts.appendSystemPromptFile, "--append-system-prompt-file")}`;
|
|
523
|
+
}
|
|
524
|
+
if (opts.appendSystemPrompt) {
|
|
525
|
+
systemPrompt += `\n\n${opts.appendSystemPrompt}`;
|
|
526
|
+
}
|
|
408
527
|
const config = {
|
|
409
528
|
provider,
|
|
410
529
|
tools,
|
|
@@ -420,6 +539,8 @@ program
|
|
|
420
539
|
// event for later resume (issue #60).
|
|
421
540
|
const conversation = [];
|
|
422
541
|
const { createSession, loadSession, saveSession } = await import("./harness/session.js");
|
|
542
|
+
// Commander rewrites --no-session-persistence to opts.sessionPersistence === false.
|
|
543
|
+
const persistSession = opts.sessionPersistence !== false;
|
|
423
544
|
let sessionId;
|
|
424
545
|
let sessionRecord;
|
|
425
546
|
if (opts.resume) {
|
|
@@ -436,7 +557,8 @@ program
|
|
|
436
557
|
else {
|
|
437
558
|
sessionRecord = createSession(provider.name, model);
|
|
438
559
|
sessionId = sessionRecord.id;
|
|
439
|
-
|
|
560
|
+
if (persistSession)
|
|
561
|
+
saveSession(sessionRecord);
|
|
440
562
|
}
|
|
441
563
|
let turnCounter = 0;
|
|
442
564
|
// Will be set to the current prompt id before each turn so hook_decision
|
|
@@ -549,12 +671,15 @@ program
|
|
|
549
671
|
}
|
|
550
672
|
// Persist after every completed turn so a later --resume picks up the
|
|
551
673
|
// history. Best-effort — a save failure shouldn't break the live session.
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
674
|
+
// Skipped entirely when --no-session-persistence was passed.
|
|
675
|
+
if (persistSession) {
|
|
676
|
+
try {
|
|
677
|
+
sessionRecord.messages = conversation.slice();
|
|
678
|
+
saveSession(sessionRecord);
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
/* save errors don't propagate to the client */
|
|
682
|
+
}
|
|
558
683
|
}
|
|
559
684
|
}
|
|
560
685
|
});
|
|
@@ -578,7 +703,16 @@ program
|
|
|
578
703
|
.option("--json-schema <schema>", "Constrain output to match a JSON schema (headless mode)")
|
|
579
704
|
.option("--input-format <format>", "Input format: text (default) or stream-json (NDJSON on stdin)")
|
|
580
705
|
.option("--replay-user-messages", "Re-emit user messages on stdout (requires stream-json output)")
|
|
706
|
+
.option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline.")
|
|
707
|
+
.option("--debug [categories]", "Enable categorized debug logs to stderr. Pass comma-separated categories (e.g. 'mcp,hooks') or no value for all. Also reads OH_DEBUG.")
|
|
708
|
+
.option("--debug-file <path>", "When --debug is set, append debug lines to this file instead of stderr.")
|
|
581
709
|
.action(async (opts) => {
|
|
710
|
+
configureDebug({
|
|
711
|
+
categories: opts.debug,
|
|
712
|
+
...(opts.debugFile ? { file: opts.debugFile } : {}),
|
|
713
|
+
});
|
|
714
|
+
const bare = opts.bare === true;
|
|
715
|
+
debug("startup", "oh chat", { bare, model: opts.model, print: !!opts.print });
|
|
582
716
|
// Load saved config as defaults (env vars + CLI flags override)
|
|
583
717
|
const savedConfig = readOhConfig();
|
|
584
718
|
const effectiveModel = opts.model ?? savedConfig?.model;
|
|
@@ -648,11 +782,31 @@ program
|
|
|
648
782
|
process.exit(0);
|
|
649
783
|
}
|
|
650
784
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
785
|
+
// `--bare` skips MCP entirely (servers, prompts, instructions). The
|
|
786
|
+
// built-in tool set is still loaded — bare is about reducing optional
|
|
787
|
+
// startup work, not stripping the agent's tool surface.
|
|
788
|
+
const mcpTools = bare ? [] : await loadMcpTools();
|
|
789
|
+
if (!bare) {
|
|
790
|
+
const mcpNames = connectedMcpServers();
|
|
791
|
+
if (mcpNames.length > 0) {
|
|
792
|
+
console.log(`[mcp] Connected: ${mcpNames.join(", ")}`);
|
|
793
|
+
}
|
|
794
|
+
// Surface MCP-server prompts (`prompts/list`) as `/server:prompt` slash
|
|
795
|
+
// commands. Errors are swallowed inside loadMcpPrompts — servers that
|
|
796
|
+
// don't implement the prompts capability return [] without throwing.
|
|
797
|
+
try {
|
|
798
|
+
const { registerMcpPromptCommands } = await import("./commands/index.js");
|
|
799
|
+
const prompts = await loadMcpPrompts();
|
|
800
|
+
registerMcpPromptCommands(prompts);
|
|
801
|
+
if (prompts.length > 0) {
|
|
802
|
+
console.log(`[mcp] Prompts: ${prompts.map((p) => `/${p.qualifiedName}`).join(", ")}`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
/* prompt registration is best-effort; never block the REPL */
|
|
807
|
+
}
|
|
655
808
|
}
|
|
809
|
+
debug("mcp", "loaded", { count: mcpTools.length, bare });
|
|
656
810
|
const tools = [...getAllTools(), ...mcpTools];
|
|
657
811
|
process.on("exit", () => disconnectMcpClients());
|
|
658
812
|
// Compute working directory and git branch
|
|
@@ -714,7 +868,7 @@ program
|
|
|
714
868
|
const qConfig = {
|
|
715
869
|
provider,
|
|
716
870
|
tools,
|
|
717
|
-
systemPrompt: buildSystemPrompt(resolvedModel),
|
|
871
|
+
systemPrompt: buildSystemPrompt(resolvedModel, { bare }),
|
|
718
872
|
permissionMode: effectivePermMode,
|
|
719
873
|
maxTurns: 20,
|
|
720
874
|
model: resolvedModel,
|
package/dist/mcp/client.d.ts
CHANGED
|
@@ -31,6 +31,29 @@ export declare class McpClient {
|
|
|
31
31
|
description?: string;
|
|
32
32
|
}>>;
|
|
33
33
|
readResource(uri: string): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* List the prompts an MCP server exposes. Returns `[]` for servers that
|
|
36
|
+
* don't implement the `prompts/list` capability — this is a normal case
|
|
37
|
+
* (most non-prompt-aware MCP servers throw a method-not-found error).
|
|
38
|
+
*
|
|
39
|
+
* Each prompt may declare named arguments; surfaced via `arguments`.
|
|
40
|
+
*/
|
|
41
|
+
listPrompts(): Promise<Array<{
|
|
42
|
+
name: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
arguments?: Array<{
|
|
45
|
+
name: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
required?: boolean;
|
|
48
|
+
}>;
|
|
49
|
+
}>>;
|
|
50
|
+
/**
|
|
51
|
+
* Get the rendered text of an MCP prompt. Server-side templates are
|
|
52
|
+
* applied with the supplied arguments. Multiple message turns are
|
|
53
|
+
* concatenated with double-newline separators — same shape OH uses for
|
|
54
|
+
* other prepended prompts.
|
|
55
|
+
*/
|
|
56
|
+
getPrompt(name: string, args?: Record<string, string>): Promise<string>;
|
|
34
57
|
callTool(name: string, args: Record<string, unknown>): Promise<string>;
|
|
35
58
|
disconnect(): void;
|
|
36
59
|
}
|
package/dist/mcp/client.js
CHANGED
|
@@ -91,6 +91,43 @@ export class McpClient {
|
|
|
91
91
|
.map((c) => c.text)
|
|
92
92
|
.join("\n");
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* List the prompts an MCP server exposes. Returns `[]` for servers that
|
|
96
|
+
* don't implement the `prompts/list` capability — this is a normal case
|
|
97
|
+
* (most non-prompt-aware MCP servers throw a method-not-found error).
|
|
98
|
+
*
|
|
99
|
+
* Each prompt may declare named arguments; surfaced via `arguments`.
|
|
100
|
+
*/
|
|
101
|
+
async listPrompts() {
|
|
102
|
+
try {
|
|
103
|
+
const res = await this.sdk.listPrompts();
|
|
104
|
+
return (res?.prompts ?? []);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get the rendered text of an MCP prompt. Server-side templates are
|
|
112
|
+
* applied with the supplied arguments. Multiple message turns are
|
|
113
|
+
* concatenated with double-newline separators — same shape OH uses for
|
|
114
|
+
* other prepended prompts.
|
|
115
|
+
*/
|
|
116
|
+
async getPrompt(name, args = {}) {
|
|
117
|
+
const res = await this.sdk.getPrompt({ name, arguments: args });
|
|
118
|
+
const messages = (res?.messages ?? []);
|
|
119
|
+
const parts = [];
|
|
120
|
+
for (const m of messages) {
|
|
121
|
+
const content = m.content;
|
|
122
|
+
if (typeof content === "string") {
|
|
123
|
+
parts.push(content);
|
|
124
|
+
}
|
|
125
|
+
else if (content && content.type === "text" && typeof content.text === "string") {
|
|
126
|
+
parts.push(content.text);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return parts.join("\n\n");
|
|
130
|
+
}
|
|
94
131
|
async callTool(name, args) {
|
|
95
132
|
// Retry up to 2 times on transport-closed / timeout errors
|
|
96
133
|
let lastErr = null;
|
package/dist/mcp/loader.d.ts
CHANGED
|
@@ -1,10 +1,57 @@
|
|
|
1
|
+
import type { McpServerConfig } from "../harness/config.js";
|
|
1
2
|
import type { Tool } from "../Tool.js";
|
|
2
|
-
/**
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Parse a `--mcp-config <path>` file. Format:
|
|
5
|
+
* - `{ "mcpServers": [...] }` — Claude Code convention (preferred)
|
|
6
|
+
* - `[ ... ]` — bare array of server configs (also accepted)
|
|
7
|
+
* - `{ "name": ..., ... }` — single-server object (also accepted)
|
|
8
|
+
*
|
|
9
|
+
* Validation is shape-only: each entry must be an object with a `name`.
|
|
10
|
+
* Connection-time validation happens in `McpClient.connect`. Throws on
|
|
11
|
+
* malformed JSON or unrecognised top-level shape.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseMcpConfigFile(path: string): McpServerConfig[];
|
|
14
|
+
export interface LoadMcpOptions {
|
|
15
|
+
/**
|
|
16
|
+
* MCP servers loaded from sources outside `.oh/config.yaml` — typically
|
|
17
|
+
* a `--mcp-config <path>` file. Merged with the config-file servers
|
|
18
|
+
* unless `strict` is set, in which case these REPLACE the config-file
|
|
19
|
+
* servers entirely.
|
|
20
|
+
*/
|
|
21
|
+
extraServers?: import("../harness/config.js").McpServerConfig[];
|
|
22
|
+
/**
|
|
23
|
+
* When `true`, ignore `cfg.mcpServers` and use only `extraServers`.
|
|
24
|
+
* No-op when `extraServers` is undefined (the config-file servers
|
|
25
|
+
* still load). Mirrors Claude Code's `--strict-mcp-config`.
|
|
26
|
+
*/
|
|
27
|
+
strict?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** Load MCP tools from .oh/config.yaml mcpServers list (and/or `--mcp-config` overrides). Returns empty array if none configured. */
|
|
30
|
+
export declare function loadMcpTools(opts?: LoadMcpOptions): Promise<Tool[]>;
|
|
4
31
|
/** Disconnect all MCP clients (call on exit) */
|
|
5
32
|
export declare function disconnectMcpClients(): void;
|
|
6
33
|
/** Names of connected MCP servers */
|
|
7
34
|
export declare function connectedMcpServers(): string[];
|
|
35
|
+
export type McpPromptHandle = {
|
|
36
|
+
/** `<server>:<prompt>` qualified name — the slash command is `/<server>:<prompt>`. */
|
|
37
|
+
qualifiedName: string;
|
|
38
|
+
description: string;
|
|
39
|
+
/** List of named arguments the prompt template expects. */
|
|
40
|
+
arguments?: Array<{
|
|
41
|
+
name: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
required?: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
/** Render the prompt with the supplied named arguments. */
|
|
46
|
+
render(args?: Record<string, string>): Promise<string>;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Enumerate prompts on every already-connected MCP server. Servers that don't
|
|
50
|
+
* implement the `prompts/list` capability return an empty list (handled
|
|
51
|
+
* inside `client.listPrompts`). Call AFTER `loadMcpTools()` so the client
|
|
52
|
+
* connections are warm.
|
|
53
|
+
*/
|
|
54
|
+
export declare function loadMcpPrompts(): Promise<McpPromptHandle[]>;
|
|
8
55
|
/** Get MCP server instructions to inject into system prompt (sandboxed with origin markers) */
|
|
9
56
|
export declare function getMcpInstructions(): string[];
|
|
10
57
|
/** List all available resources across connected MCP servers */
|