@zhijiewang/openharness 2.18.0 → 2.19.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 CHANGED
@@ -32,8 +32,8 @@ AI coding agent in your terminal. Works with any LLM -- free local models or clo
32
32
  - [Quick Start](#quick-start)
33
33
  - [Why OpenHarness?](#why-openharness)
34
34
  - [Terminal UI](#terminal-ui)
35
- - [Tools (37)](#tools-37)
36
- - [Slash Commands (33)](#slash-commands-33)
35
+ - [Tools (43)](#tools-43)
36
+ - [Slash Commands](#slash-commands)
37
37
  - [Permission Modes](#permission-modes)
38
38
  - [Hooks](#hooks)
39
39
  - [Checkpoints & Rewind](#checkpoints--rewind)
@@ -61,6 +61,8 @@ That's it. OpenHarness auto-detects Ollama and starts chatting. No API key neede
61
61
 
62
62
  **Python SDK:** there's also an official Python SDK for driving `oh` from Python programs (notebooks, batch scripts, ML pipelines). Install with `pip install openharness-sdk` after the npm install (the PyPI distribution is `openharness-sdk` because the unqualified name is taken), then `from openharness import query`. See [`python/README.md`](python/README.md).
63
63
 
64
+ **TypeScript SDK:** drive `oh` from Node.js (VS Code extensions, Electron apps, build scripts) with `@zhijiewang/openharness-sdk` — `npm install @zhijiewang/openharness-sdk`, then `import { query, OpenHarnessClient, tool } from "@zhijiewang/openharness-sdk"`. Mirrors the Python SDK surface (streaming events, stateful sessions, custom tools, permission callback, session resume). See [`packages/sdk/README.md`](packages/sdk/README.md).
65
+
64
66
  ```bash
65
67
  oh init # interactive setup wizard (provider + cybergotchi)
66
68
  oh # auto-detect local model
@@ -142,12 +144,13 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
142
144
 
143
145
  Available variables: `{model}`, `{tokens}` (input↑ output↓), `{cost}` ($X.XXXX), `{ctx}` (context usage bar). Empty sections are automatically collapsed.
144
146
 
145
- ## Tools (37)
147
+ ## Tools (43)
146
148
 
147
149
  | Tool | Risk | Description |
148
150
  |------|------|-------------|
149
151
  | **Core** | | |
150
152
  | Bash | high | Execute shell commands with live streaming output (AST safety analysis) |
153
+ | PowerShell | high | Execute PowerShell commands (Windows-native scripting) |
151
154
  | Read | low | Read files with line ranges, PDF support |
152
155
  | ImageRead | low | Read images/PDFs for multimodal analysis |
153
156
  | Write | medium | Create or overwrite files |
@@ -167,6 +170,7 @@ Available variables: `{model}`, `{tokens}` (input↑ output↓), `{cost}` ($X.XX
167
170
  | TaskGet | low | Get task details |
168
171
  | TaskStop | low | Stop a running task |
169
172
  | TaskOutput | low | Get task output |
173
+ | TodoWrite | low | Manage session task checklist (Claude Code-compatible) |
170
174
  | **Agents** | | |
171
175
  | Agent | medium | Spawn a sub-agent (with role specialization) |
172
176
  | ParallelAgent | medium | Dispatch multiple agents with DAG dependencies |
@@ -176,9 +180,12 @@ Available variables: `{model}`, `{tokens}` (input↑ output↓), `{cost}` ($X.XX
176
180
  | CronCreate | medium | Schedule recurring tasks |
177
181
  | CronDelete | medium | Remove scheduled tasks |
178
182
  | CronList | low | List all scheduled tasks |
183
+ | ScheduleWakeup | low | Self-pace the next /loop iteration (cache-aware) |
179
184
  | **Planning** | | |
180
185
  | EnterPlanMode | low | Enter structured planning mode |
181
186
  | ExitPlanMode | low | Exit planning mode |
187
+ | **Pipelines** | | |
188
+ | Pipeline | medium | Run a sequence of tasks with output passed between steps |
182
189
  | **Code Intelligence** | | |
183
190
  | Diagnostics | low | LSP-based code diagnostics |
184
191
  | NotebookEdit | medium | Edit Jupyter notebooks |
@@ -186,6 +193,7 @@ Available variables: `{model}`, `{tokens}` (input↑ output↓), `{cost}` ($X.XX
186
193
  | Memory | low | Save/list/search persistent memories |
187
194
  | Skill | low | Invoke a skill from .oh/skills/ |
188
195
  | ToolSearch | low | Find tools by description |
196
+ | SessionSearch | low | Search prior sessions for relevant context |
189
197
  | **MCP** | | |
190
198
  | ListMcpResources | low | List resources from connected MCP servers |
191
199
  | ReadMcpResource | low | Read a specific MCP resource by URI |
@@ -194,12 +202,13 @@ Available variables: `{model}`, `{tokens}` (input↑ output↓), `{cost}` ($X.XX
194
202
  | ExitWorktree | medium | Remove a git worktree |
195
203
  | **Process** | | |
196
204
  | KillProcess | high | Stop processes by PID or name |
205
+ | Monitor | medium | Run a background command and stream each output line back to the agent |
197
206
 
198
207
  Low-risk read-only tools auto-approve. Medium and high risk tools require confirmation in `ask` mode. Use `--trust` or `--auto` to skip prompts.
199
208
 
200
- ## Slash Commands (33)
209
+ ## Slash Commands
201
210
 
202
- Type these during a chat session. Aliases: `/q` exit, `/h` help, `/c` commit, `/m` model, `/s` status.
211
+ Over 80 commands are registered. The most-used ones are grouped below; see `/help` in-session for the full list. Aliases: `/q` exit, `/h` help, `/c` commit, `/m` model, `/s` status.
203
212
 
204
213
  **Session:**
205
214
  | Command | Description |
@@ -289,11 +298,29 @@ hooks:
289
298
  command: "scripts/cleanup.sh"
290
299
  ```
291
300
 
292
- **Event types:**
293
- - `sessionStart` — fires once when the session begins
294
- - `preToolUse` fires before each tool call; **exit code 1 blocks the tool** and returns an error to the model
295
- - `postToolUse` — fires after each tool call completes
296
- - `sessionEnd` fires when the session ends
301
+ **Event types** (17 total):
302
+
303
+ | Event | When it fires | Can block? |
304
+ |-------|---------------|------------|
305
+ | `sessionStart` | Session begins | |
306
+ | `sessionEnd` | Session ends | — |
307
+ | `turnStart` | Top-level agent turn begins (after user prompt accepted) | — |
308
+ | `turnStop` | Top-level agent turn ends (mirrors Claude Code's `Stop`) | — |
309
+ | `userPromptSubmit` | Before user prompt reaches the LLM | yes — `decision: deny` |
310
+ | `preToolUse` | Before each tool call | yes — exit code 1 / `decision: deny` |
311
+ | `postToolUse` | After successful tool execution | — |
312
+ | `postToolUseFailure` | After tool throws or returns `isError: true` | — |
313
+ | `permissionRequest` | When a tool needs approval (between `preToolUse` and the prompt) | yes — `decision: allow\|deny\|ask` |
314
+ | `fileChanged` | After a tool modifies a file | — |
315
+ | `cwdChanged` | After working directory changes | — |
316
+ | `subagentStart` | A sub-agent is spawned | — |
317
+ | `subagentStop` | A sub-agent completes | — |
318
+ | `preCompact` | Before conversation compaction | — |
319
+ | `postCompact` | After conversation compaction | — |
320
+ | `configChange` | `.oh/config.yaml` is modified during the session | — |
321
+ | `notification` | A notification is dispatched | — |
322
+
323
+ Live introspection: run `/hooks` in-session to see exactly which hooks are loaded, grouped by event.
297
324
 
298
325
  **Environment variables** available to hook scripts:
299
326
 
@@ -303,10 +330,16 @@ hooks:
303
330
  | `OH_TOOL_NAME` | Name of the tool being called (tool events only) |
304
331
  | `OH_TOOL_ARGS` | JSON-encoded tool arguments (tool events only) |
305
332
  | `OH_TOOL_OUTPUT` | JSON-encoded tool output (`postToolUse` only) |
333
+ | `OH_TOOL_INPUT_JSON` | Full JSON tool input (tool events only) |
334
+ | `OH_SESSION_ID` / `OH_MODEL` / `OH_PROVIDER` / `OH_PERMISSION_MODE` | Current session context |
335
+ | `OH_COST` / `OH_TOKENS` | Running cost and token totals |
336
+ | `OH_FILE_PATH` | Path that changed (`fileChanged` only) |
337
+ | `OH_NEW_CWD` | New working directory (`cwdChanged` only) |
338
+ | `OH_TURN_NUMBER` / `OH_TURN_REASON` | Turn boundary context (`turnStart` / `turnStop`) |
306
339
 
307
- Use `match` to restrict a hook to a specific tool name (e.g., `match: Bash` only triggers for the Bash tool).
340
+ Use `match` to restrict a hook to a specific tool name (e.g., `match: Bash` only triggers for the Bash tool). Substring, glob (`Cron*`), and `/regex/flags` patterns are all supported.
308
341
 
309
- See [docs/hooks.md](docs/hooks.md) for the full event reference including the new `userPromptSubmit`, `permissionRequest`, and `postToolUseFailure` events.
342
+ Set `jsonIO: true` on a `command` hook to opt into structured JSON I/O — the harness sends `{event, ...context}` on stdin and reads `{decision, reason, hookSpecificOutput}` from stdout. HTTP hooks accept the same response shape. See [docs/hooks.md](docs/hooks.md) for the full reference.
310
343
 
311
344
  ## Cybergotchi
312
345
 
package/README.zh-CN.md CHANGED
@@ -32,8 +32,8 @@
32
32
  - [快速开始](#快速开始)
33
33
  - [为什么选择 OpenHarness?](#为什么选择-openharness)
34
34
  - [终端界面](#终端界面)
35
- - [工具(37 个)](#工具37-个)
36
- - [斜杠命令(33 个)](#斜杠命令33-个)
35
+ - [工具(43 个)](#工具43-个)
36
+ - [斜杠命令](#斜杠命令)
37
37
  - [权限模式](#权限模式)
38
38
  - [钩子](#钩子)
39
39
  - [检查点与回滚](#检查点与回滚)
@@ -61,6 +61,8 @@ oh
61
61
 
62
62
  **Python SDK:** 我们还提供了官方的 Python SDK,可以在 Python 程序中驱动 `oh`(笔记本、批处理脚本、ML 流水线)。在 npm 安装之后,使用 `pip install openharness-sdk` 安装(PyPI 分发名为 `openharness-sdk`,因为未加后缀的名称已被占用),然后 `from openharness import query`。详见 [`python/README.md`](python/README.md)。
63
63
 
64
+ **TypeScript SDK:** 同样有官方的 TypeScript SDK,可以在 Node.js(VS Code 插件、Electron 应用、构建脚本等)中驱动 `oh`:使用 `@zhijiewang/openharness-sdk` —— 通过 `npm install @zhijiewang/openharness-sdk` 安装,然后 `import { query, OpenHarnessClient, tool } from "@zhijiewang/openharness-sdk"`。功能与 Python SDK 对等(流式事件、有状态会话、自定义工具、权限回调、会话恢复)。详见 [`packages/sdk/README.md`](packages/sdk/README.md)。
65
+
64
66
  ```bash
65
67
  oh init # 交互式安装向导(模型提供商 + 电子宠物)
66
68
  oh # 自动检测本地模型
@@ -142,12 +144,13 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
142
144
 
143
145
  可用变量:`{model}`、`{tokens}`(输入↑ 输出↓)、`{cost}`($X.XXXX)、`{ctx}`(上下文占用条)。空片段会自动折叠。
144
146
 
145
- ## 工具(37 个)
147
+ ## 工具(43 个)
146
148
 
147
149
  | 工具 | 风险 | 描述 |
148
150
  |------|------|-------------|
149
151
  | **核心** | | |
150
152
  | Bash | 高 | 执行 shell 命令并实时流式输出(AST 安全分析) |
153
+ | PowerShell | 高 | 执行 PowerShell 命令(Windows 原生脚本) |
151
154
  | Read | 低 | 按行范围读取文件,支持 PDF |
152
155
  | ImageRead | 低 | 读取图片/PDF 以进行多模态分析 |
153
156
  | Write | 中 | 创建或覆盖文件 |
@@ -167,6 +170,7 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
167
170
  | TaskGet | 低 | 获取任务详情 |
168
171
  | TaskStop | 低 | 停止正在运行的任务 |
169
172
  | TaskOutput | 低 | 获取任务输出 |
173
+ | TodoWrite | 低 | 管理会话级 todo 列表(兼容 Claude Code) |
170
174
  | **代理** | | |
171
175
  | Agent | 中 | 派生一个子代理(可指定角色) |
172
176
  | ParallelAgent | 中 | 派发多个代理并支持 DAG 依赖 |
@@ -176,9 +180,12 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
176
180
  | CronCreate | 中 | 创建定时任务 |
177
181
  | CronDelete | 中 | 删除定时任务 |
178
182
  | CronList | 低 | 列出所有定时任务 |
183
+ | ScheduleWakeup | 低 | 在 /loop 中自适应安排下一次触发(缓存感知) |
179
184
  | **规划** | | |
180
185
  | EnterPlanMode | 低 | 进入结构化规划模式 |
181
186
  | ExitPlanMode | 低 | 退出规划模式 |
187
+ | **流水线** | | |
188
+ | Pipeline | 中 | 顺序执行一连串子任务,把每一步的输出作为下一步的输入 |
182
189
  | **代码智能** | | |
183
190
  | Diagnostics | 低 | 基于 LSP 的代码诊断 |
184
191
  | NotebookEdit | 中 | 编辑 Jupyter notebook |
@@ -186,6 +193,7 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
186
193
  | Memory | 低 | 保存/列出/搜索持久化记忆 |
187
194
  | Skill | 低 | 调用 .oh/skills/ 下的技能 |
188
195
  | ToolSearch | 低 | 按描述查找工具 |
196
+ | SessionSearch | 低 | 在历史会话中搜索相关上下文 |
189
197
  | **MCP** | | |
190
198
  | ListMcpResources | 低 | 列出已连接 MCP 服务器上的资源 |
191
199
  | ReadMcpResource | 低 | 按 URI 读取指定的 MCP 资源 |
@@ -194,12 +202,13 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
194
202
  | ExitWorktree | 中 | 移除一个 git worktree |
195
203
  | **进程** | | |
196
204
  | KillProcess | 高 | 按 PID 或名称停止进程 |
205
+ | Monitor | 中 | 在后台运行命令,并把每一行输出实时反馈给代理 |
197
206
 
198
207
  低风险只读工具会自动批准。在 `ask` 模式下,中高风险工具需要确认。使用 `--trust` 或 `--auto` 可跳过提示。
199
208
 
200
- ## 斜杠命令(33 个)
209
+ ## 斜杠命令
201
210
 
202
- 在对话中输入这些命令。别名:`/q` 退出、`/h` 帮助、`/c` 提交、`/m` 模型、`/s` 状态。
211
+ OH 注册了 80+ 个斜杠命令;下表只列出最常用的一部分。在会话中运行 `/help` 可以看到完整列表。别名:`/q` 退出、`/h` 帮助、`/c` 提交、`/m` 模型、`/s` 状态。
203
212
 
204
213
  **会话:**
205
214
  | 命令 | 描述 |
@@ -289,11 +298,29 @@ hooks:
289
298
  command: "scripts/cleanup.sh"
290
299
  ```
291
300
 
292
- **事件类型:**
293
- - `sessionStart` —— 会话开始时触发一次
294
- - `preToolUse` —— 每次工具调用前触发;**退出码 1 会阻止该工具**并向模型返回错误
295
- - `postToolUse` —— 每次工具调用完成后触发
296
- - `sessionEnd` —— 会话结束时触发
301
+ **事件类型**(共 17 个):
302
+
303
+ | 事件 | 触发时机 | 是否可阻止 |
304
+ |-------|---------------|------------|
305
+ | `sessionStart` | 会话开始 | — |
306
+ | `sessionEnd` | 会话结束 | — |
307
+ | `turnStart` | 顶层代理回合开始(用户提示词被接受后) | — |
308
+ | `turnStop` | 顶层代理回合结束(对应 Claude Code 的 `Stop`) | — |
309
+ | `userPromptSubmit` | 用户提示词到达 LLM 之前 | 是 —— `decision: deny` |
310
+ | `preToolUse` | 工具调用之前 | 是 —— 退出码 1 / `decision: deny` |
311
+ | `postToolUse` | 工具成功执行之后 | — |
312
+ | `postToolUseFailure` | 工具抛错或返回 `isError: true` | — |
313
+ | `permissionRequest` | 工具需要授权时(`preToolUse` 与询问之间) | 是 —— `decision: allow\|deny\|ask` |
314
+ | `fileChanged` | 工具修改文件之后 | — |
315
+ | `cwdChanged` | 工作目录变更之后 | — |
316
+ | `subagentStart` | 子代理被派生 | — |
317
+ | `subagentStop` | 子代理完成 | — |
318
+ | `preCompact` | 对话压缩之前 | — |
319
+ | `postCompact` | 对话压缩之后 | — |
320
+ | `configChange` | 会话过程中 `.oh/config.yaml` 被修改 | — |
321
+ | `notification` | 通知被派发 | — |
322
+
323
+ 实时查看:在会话中运行 `/hooks` 可以按事件分组查看当前已加载的钩子。
297
324
 
298
325
  **环境变量**(钩子脚本可用):
299
326
 
@@ -303,10 +330,16 @@ hooks:
303
330
  | `OH_TOOL_NAME` | 正在调用的工具名(仅工具类事件) |
304
331
  | `OH_TOOL_ARGS` | JSON 编码的工具参数(仅工具类事件) |
305
332
  | `OH_TOOL_OUTPUT` | JSON 编码的工具输出(仅 `postToolUse`) |
333
+ | `OH_TOOL_INPUT_JSON` | 完整的 JSON 工具输入(仅工具类事件) |
334
+ | `OH_SESSION_ID` / `OH_MODEL` / `OH_PROVIDER` / `OH_PERMISSION_MODE` | 当前会话上下文 |
335
+ | `OH_COST` / `OH_TOKENS` | 累计费用与 token 数 |
336
+ | `OH_FILE_PATH` | 变更的文件路径(仅 `fileChanged`) |
337
+ | `OH_NEW_CWD` | 新的工作目录(仅 `cwdChanged`) |
338
+ | `OH_TURN_NUMBER` / `OH_TURN_REASON` | 回合边界上下文(`turnStart` / `turnStop`) |
306
339
 
307
- 使用 `match` 将钩子限定到特定工具名(例如 `match: Bash` 仅对 Bash 工具触发)。
340
+ 使用 `match` 将钩子限定到特定工具名(例如 `match: Bash` 仅对 Bash 工具触发)。支持子串、glob(如 `Cron*`)和 `/regex/flags` 三种匹配方式。
308
341
 
309
- 完整事件参考(包括新增的 `userPromptSubmit`、`permissionRequest`、`postToolUseFailure` 事件)见 [docs/hooks.md](docs/hooks.md)。
342
+ `command` 钩子设置 `jsonIO: true` 即可启用结构化 JSON I/O —— 框架在 stdin 上发送 `{event, ...context}`,并从 stdout 读取 `{decision, reason, hookSpecificOutput}`。HTTP 钩子接受同样的响应格式。完整参考见 [docs/hooks.md](docs/hooks.md)。
310
343
 
311
344
  ## 电子宠物 Cybergotchi
312
345
 
package/dist/main.js CHANGED
@@ -27,6 +27,7 @@ import { connectedMcpServers, disconnectMcpClients, getMcpInstructions, loadMcpT
27
27
  import { loadOutputStyle } from "./outputStyles/index.js";
28
28
  import { getAllTools } from "./tools.js";
29
29
  import { validateAgainstJsonSchema } from "./utils/json-schema.js";
30
+ import { parseMaxBudgetUsd } from "./utils/parse-budget.js";
30
31
  const _require = createRequire(import.meta.url);
31
32
  const VERSION = _require("../package.json").version;
32
33
  const BANNER = ` ___
@@ -74,6 +75,20 @@ You have access to tools for reading, writing, and searching files, running shel
74
75
  - When referencing code, include file_path:line_number.
75
76
  - Do not restate what the user said. Do not add trailing summaries unless asked.
76
77
  - Keep responses short and direct. If you can say it in one sentence, don't use three.`;
78
+ /**
79
+ * Parse the `--max-budget-usd` CLI argument into a positive USD amount, or
80
+ * exit 2 with an error message. The pure parser lives in
81
+ * `src/utils/parse-budget.ts` so it can be unit-tested without spawning the
82
+ * CLI; this thin wrapper handles the exit-on-failure side effect.
83
+ */
84
+ function parseMaxBudgetUsdOrExit(raw) {
85
+ const result = parseMaxBudgetUsd(raw);
86
+ if (!result.ok) {
87
+ process.stderr.write(`Error: ${result.message}\n`);
88
+ process.exit(2);
89
+ }
90
+ return result.value;
91
+ }
77
92
  function buildSystemPrompt(model) {
78
93
  const cfg = readOhConfig();
79
94
  // Output-style preface (first — sets personality for everything that follows).
@@ -136,6 +151,7 @@ program
136
151
  .option("--disallowed-tools <tools>", "Comma-separated list of disallowed tools")
137
152
  .option("--resume <id>", "Resume a saved session (replays its message history before this prompt)")
138
153
  .option("--setting-sources <sources>", "Comma-separated list of setting sources to merge (e.g. 'user,project,local'). Mirrors Claude Code's setting_sources.")
154
+ .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.")
139
155
  .action(async (promptArg, opts) => {
140
156
  // Read from stdin if prompt is "-" or omitted and stdin is not a TTY
141
157
  let prompt;
@@ -201,6 +217,7 @@ program
201
217
  permissionMode,
202
218
  maxTurns: parseInt(opts.maxTurns, 10),
203
219
  model,
220
+ ...(opts.maxBudgetUsd !== undefined ? { maxCost: parseMaxBudgetUsdOrExit(opts.maxBudgetUsd) } : {}),
204
221
  };
205
222
  const outputFormat = opts.json ? "json" : (opts.outputFormat ?? "text");
206
223
  let fullOutput = "";
@@ -210,26 +227,36 @@ program
210
227
  // history into the conversation before the new prompt. If the session can't
211
228
  // be loaded (missing file, malformed JSON), fail early with a clear error
212
229
  // rather than silently starting fresh.
230
+ //
231
+ // When --resume is NOT passed, mint a fresh session record so SDK callers
232
+ // can capture its id from the session_start event and pass it back as
233
+ // --resume <id> on a later run. Without this, every fresh `oh run` was
234
+ // a programmatic dead-end for resumption (issue #60).
235
+ const { createSession, loadSession, saveSession } = await import("./harness/session.js");
213
236
  let priorMessages;
214
237
  let sessionId;
238
+ let sessionRecord;
215
239
  if (opts.resume) {
216
- const { loadSession } = await import("./harness/session.js");
217
240
  try {
218
- const src = loadSession(opts.resume);
219
- priorMessages = src.messages;
220
- sessionId = src.id;
241
+ sessionRecord = loadSession(opts.resume);
242
+ priorMessages = sessionRecord.messages;
243
+ sessionId = sessionRecord.id;
221
244
  }
222
245
  catch {
223
246
  process.stderr.write(`Error: could not load session '${opts.resume}'\n`);
224
247
  process.exit(1);
225
248
  }
226
249
  }
250
+ else {
251
+ sessionRecord = createSession(provider.name, model);
252
+ sessionId = sessionRecord.id;
253
+ saveSession(sessionRecord);
254
+ }
227
255
  if (outputFormat === "stream-json") {
228
256
  // Emit a session_start event so SDK callers can capture the id for
229
- // later resume (fires once, before turnStart).
230
- if (sessionId) {
231
- console.log(JSON.stringify({ type: "session_start", sessionId }));
232
- }
257
+ // later resume (fires once, before turnStart). Always emitted now —
258
+ // fresh runs mint a sessionId above.
259
+ console.log(JSON.stringify({ type: "session_start", sessionId }));
233
260
  setHookDecisionObserver((n) => {
234
261
  console.log(JSON.stringify({
235
262
  type: "hook_decision",
@@ -320,6 +347,22 @@ program
320
347
  else if (outputFormat === "text") {
321
348
  process.stdout.write("\n");
322
349
  }
350
+ // Persist this run's contribution so a later --resume <sessionId> finds
351
+ // the user/assistant pair. Tool details are intentionally elided —
352
+ // they're per-tool ephemerals; the assistant's final text is what
353
+ // matters for context resumption. Mirrors the REPL's save-on-exit pattern
354
+ // (src/components/REPL.tsx:120) but at one-shot scope.
355
+ try {
356
+ const { createUserMessage, createAssistantMessage } = await import("./types/message.js");
357
+ const newMessages = [...(priorMessages ?? []), createUserMessage(prompt)];
358
+ if (fullOutput)
359
+ newMessages.push(createAssistantMessage(fullOutput));
360
+ sessionRecord.messages = newMessages;
361
+ saveSession(sessionRecord);
362
+ }
363
+ catch {
364
+ /* persistence is best-effort — never fail the user's run on a save error */
365
+ }
323
366
  });
324
367
  // ── `oh session`: long-lived stateful session for the Python SDK ──
325
368
  program
@@ -335,6 +378,7 @@ program
335
378
  .option("--system-prompt <prompt>", "Override the system prompt")
336
379
  .option("--resume <id>", "Resume a saved session (seeds the conversation with its prior message history)")
337
380
  .option("--setting-sources <sources>", "Comma-separated list of setting sources to merge (mirrors Claude Code's setting_sources).")
381
+ .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.")
338
382
  .action(async (opts) => {
339
383
  const settingSources = parseSettingSources(opts.settingSources);
340
384
  const savedConfig = readOhConfig(undefined, settingSources);
@@ -368,23 +412,32 @@ program
368
412
  permissionMode,
369
413
  maxTurns: parseInt(opts.maxTurns, 10),
370
414
  model,
415
+ ...(opts.maxBudgetUsd !== undefined ? { maxCost: parseMaxBudgetUsdOrExit(opts.maxBudgetUsd) } : {}),
371
416
  };
372
417
  // Conversation history, shared across all prompts for this process.
373
- // Seeded from a prior session when --resume <id> is passed.
418
+ // Seeded from a prior session when --resume <id> is passed; otherwise a
419
+ // fresh session is minted so the SDK can capture the id from the `ready`
420
+ // event for later resume (issue #60).
374
421
  const conversation = [];
422
+ const { createSession, loadSession, saveSession } = await import("./harness/session.js");
375
423
  let sessionId;
424
+ let sessionRecord;
376
425
  if (opts.resume) {
377
- const { loadSession } = await import("./harness/session.js");
378
426
  try {
379
- const src = loadSession(opts.resume);
380
- conversation.push(...src.messages);
381
- sessionId = src.id;
427
+ sessionRecord = loadSession(opts.resume);
428
+ conversation.push(...sessionRecord.messages);
429
+ sessionId = sessionRecord.id;
382
430
  }
383
431
  catch {
384
432
  console.log(JSON.stringify({ type: "error", message: `could not load session '${opts.resume}'` }));
385
433
  return;
386
434
  }
387
435
  }
436
+ else {
437
+ sessionRecord = createSession(provider.name, model);
438
+ sessionId = sessionRecord.id;
439
+ saveSession(sessionRecord);
440
+ }
388
441
  let turnCounter = 0;
389
442
  // Will be set to the current prompt id before each turn so hook_decision
390
443
  // events can be demultiplexed by the client.
@@ -494,6 +547,15 @@ program
494
547
  for (const tr of toolResults) {
495
548
  conversation.push(createToolResultMessage({ callId: tr.callId, output: tr.output, isError: tr.isError }));
496
549
  }
550
+ // Persist after every completed turn so a later --resume picks up the
551
+ // history. Best-effort — a save failure shouldn't break the live session.
552
+ try {
553
+ sessionRecord.messages = conversation.slice();
554
+ saveSession(sessionRecord);
555
+ }
556
+ catch {
557
+ /* save errors don't propagate to the client */
558
+ }
497
559
  }
498
560
  });
499
561
  // ── Default command: just run `openharness` to start chatting ──
@@ -9,6 +9,19 @@ export declare class OllamaProvider implements Provider {
9
9
  private baseUrl;
10
10
  private defaultModel;
11
11
  constructor(config: ProviderConfig);
12
+ /**
13
+ * Estimate the prompt size and pick a `num_ctx` for Ollama. Without this
14
+ * Ollama defaults to a 2048-token context window — anything bigger gets
15
+ * silently truncated server-side. OH's typical system prompt + tool list
16
+ * already pushes ~4 K, so multi-turn chats lose prior turns and the model
17
+ * appears to "forget" what was just said. See issue #61.
18
+ *
19
+ * Strategy: rough char/4 token estimate, +1 K headroom for the response,
20
+ * then round up to the next power of 2 ≥ 8192. Capped at 32 K to keep KV
21
+ * cache bounded; users with bigger models can override via
22
+ * `OLLAMA_NUM_CTX`.
23
+ */
24
+ private computeNumCtx;
12
25
  private convertMessages;
13
26
  private convertTools;
14
27
  stream(messages: Message[], systemPrompt: string, tools?: APIToolDef[], model?: string): AsyncGenerator<StreamEvent, void>;
@@ -10,6 +10,45 @@ export class OllamaProvider {
10
10
  this.baseUrl = (config.baseUrl ?? "http://localhost:11434").replace(/\/$/, "");
11
11
  this.defaultModel = config.defaultModel ?? "llama3.1";
12
12
  }
13
+ /**
14
+ * Estimate the prompt size and pick a `num_ctx` for Ollama. Without this
15
+ * Ollama defaults to a 2048-token context window — anything bigger gets
16
+ * silently truncated server-side. OH's typical system prompt + tool list
17
+ * already pushes ~4 K, so multi-turn chats lose prior turns and the model
18
+ * appears to "forget" what was just said. See issue #61.
19
+ *
20
+ * Strategy: rough char/4 token estimate, +1 K headroom for the response,
21
+ * then round up to the next power of 2 ≥ 8192. Capped at 32 K to keep KV
22
+ * cache bounded; users with bigger models can override via
23
+ * `OLLAMA_NUM_CTX`.
24
+ */
25
+ computeNumCtx(messages, systemPrompt, tools) {
26
+ const override = process.env.OLLAMA_NUM_CTX;
27
+ if (override) {
28
+ const parsed = Number(override);
29
+ if (Number.isFinite(parsed) && parsed > 0)
30
+ return Math.floor(parsed);
31
+ }
32
+ const estimate = (s) => Math.ceil(s.length / 4);
33
+ let total = systemPrompt ? estimate(systemPrompt) : 0;
34
+ for (const m of messages) {
35
+ total += estimate(m.content);
36
+ if (m.toolCalls)
37
+ for (const tc of m.toolCalls)
38
+ total += estimate(JSON.stringify(tc.arguments));
39
+ if (m.toolResults)
40
+ for (const tr of m.toolResults)
41
+ total += estimate(tr.output);
42
+ }
43
+ if (tools)
44
+ for (const t of tools)
45
+ total += estimate(JSON.stringify(t));
46
+ const padded = Math.ceil(total * 1.25) + 1024;
47
+ let nc = 8192;
48
+ while (nc < padded && nc < 32768)
49
+ nc *= 2;
50
+ return Math.min(nc, 32768);
51
+ }
13
52
  convertMessages(messages, systemPrompt) {
14
53
  const converted = [];
15
54
  if (systemPrompt) {
@@ -69,6 +108,7 @@ export class OllamaProvider {
69
108
  model: m,
70
109
  messages: msgs,
71
110
  stream: true,
111
+ options: { num_ctx: this.computeNumCtx(messages, systemPrompt, tools) },
72
112
  };
73
113
  const ollamaTools = this.convertTools(tools);
74
114
  if (ollamaTools)
@@ -219,6 +259,7 @@ export class OllamaProvider {
219
259
  model: m,
220
260
  messages: msgs,
221
261
  stream: false,
262
+ options: { num_ctx: this.computeNumCtx(messages, systemPrompt, tools) },
222
263
  };
223
264
  const ollamaTools = this.convertTools(tools);
224
265
  if (ollamaTools)
@@ -42,11 +42,12 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
42
42
  // Permission check
43
43
  const perm = checkPermission(permissionMode, tool.riskLevel, tool.isReadOnly(parsed.data), tool.name, parsed.data);
44
44
  if (!perm.allowed) {
45
- if (perm.reason === "needs-approval" && askUser) {
46
- const { formatToolArgs } = await import("../utils/tool-summary.js");
47
- const description = formatToolArgs(tool.name, toolCall.arguments);
48
- // Hook: permissionRequest fires between preToolUse and the interactive askUser prompt.
49
- // Only fires when checkPermission says "needs-approval" AND askUser is provided.
45
+ if (perm.reason === "needs-approval") {
46
+ // Hook: permissionRequest fires whenever checkPermission says
47
+ // "needs-approval", in both interactive and headless modes. Configured
48
+ // hooks get first say; if they return "ask" or have no decision, we
49
+ // fall through to the interactive prompt when one is available, or
50
+ // fail-closed deny in headless mode (issue #62).
50
51
  const hookOutcome = await emitHookWithOutcome("permissionRequest", {
51
52
  toolName: tool.name,
52
53
  toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
@@ -55,19 +56,30 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
55
56
  permissionAction: "ask",
56
57
  });
57
58
  if (hookOutcome.permissionDecision === "allow") {
58
- // Hook granted permission — skip interactive prompt and proceed to execution.
59
+ // Hook granted permission — proceed to execution.
59
60
  }
60
61
  else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
61
62
  const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
62
63
  return { output: `Permission denied by hook${reason}`, isError: true };
63
64
  }
64
- else {
65
- // "ask" or no decision → fall through to interactive prompt
65
+ else if (askUser) {
66
+ // "ask" or no decision → interactive prompt when available
67
+ const { formatToolArgs } = await import("../utils/tool-summary.js");
68
+ const description = formatToolArgs(tool.name, toolCall.arguments);
66
69
  const allowed = await askUser(tool.name, description, tool.riskLevel);
67
70
  if (!allowed) {
68
71
  return { output: "Permission denied by user.", isError: true };
69
72
  }
70
73
  }
74
+ else {
75
+ // Headless mode with no hook decision and no interactive prompt:
76
+ // fail-closed deny. SDK consumers should configure a permissionRequest
77
+ // hook (or use canUseTool) to make per-call decisions.
78
+ return {
79
+ output: "Permission denied: needs-approval (no interactive prompt available; configure a permissionRequest hook to gate this tool)",
80
+ isError: true,
81
+ };
82
+ }
71
83
  }
72
84
  else {
73
85
  return { output: `Permission denied: ${perm.reason}`, isError: true };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Parse the `--max-budget-usd` CLI argument into a positive USD amount.
3
+ *
4
+ * Accepts plain decimals (`5`, `0.50`, `2.5`) and an optional leading `$`.
5
+ * Negative or zero values are rejected — a budget of zero would block the
6
+ * very first call before any cost has accumulated.
7
+ *
8
+ * Returns `{ ok: true, value }` on success or `{ ok: false, message }` on
9
+ * invalid input. The CLI wrapper translates failures into a stderr message
10
+ * and exit code 2.
11
+ */
12
+ export type ParseBudgetResult = {
13
+ ok: true;
14
+ value: number;
15
+ } | {
16
+ ok: false;
17
+ message: string;
18
+ };
19
+ export declare function parseMaxBudgetUsd(raw: string): ParseBudgetResult;
20
+ //# sourceMappingURL=parse-budget.d.ts.map
@@ -0,0 +1,12 @@
1
+ export function parseMaxBudgetUsd(raw) {
2
+ const cleaned = raw.replace(/^\$/, "").trim();
3
+ if (cleaned === "") {
4
+ return { ok: false, message: `--max-budget-usd must be a positive USD amount, got '${raw}'` };
5
+ }
6
+ const n = Number(cleaned);
7
+ if (!Number.isFinite(n) || n <= 0) {
8
+ return { ok: false, message: `--max-budget-usd must be a positive USD amount, got '${raw}'` };
9
+ }
10
+ return { ok: true, value: n };
11
+ }
12
+ //# sourceMappingURL=parse-budget.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,17 +22,23 @@
22
22
  "README.md",
23
23
  "LICENSE"
24
24
  ],
25
+ "workspaces": [
26
+ "packages/sdk"
27
+ ],
25
28
  "scripts": {
26
29
  "dev": "tsx src/main.tsx",
27
30
  "build": "tsc",
31
+ "build:sdk": "npm --workspace @zhijiewang/openharness-sdk run build",
28
32
  "prepare": "husky",
29
33
  "prepublishOnly": "npm run build",
30
- "test": "node scripts/test.mjs",
34
+ "test": "node scripts/test.mjs && npm --workspace @zhijiewang/openharness-sdk run test",
35
+ "test:cli": "node scripts/test.mjs",
36
+ "test:sdk": "npm --workspace @zhijiewang/openharness-sdk run test",
31
37
  "test:coverage": "node scripts/coverage.mjs",
32
- "typecheck": "tsc --noEmit",
33
- "lint": "biome check src/",
34
- "lint:fix": "biome check --write src/",
35
- "format": "biome format --write src/",
38
+ "typecheck": "tsc --noEmit && npm --workspace @zhijiewang/openharness-sdk run typecheck",
39
+ "lint": "biome check src/ packages/sdk/src/",
40
+ "lint:fix": "biome check --write src/ packages/sdk/src/",
41
+ "format": "biome format --write src/ packages/sdk/src/",
36
42
  "start": "node dist/main.js"
37
43
  },
38
44
  "dependencies": {