oharness 0.1.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/dist/bin/sync.d.ts +3 -0
- package/dist/bin/sync.js +4 -0
- package/dist/bin/sync.js.map +1 -0
- package/dist/bin/web.d.ts +10 -0
- package/dist/bin/web.js +11 -0
- package/dist/bin/web.js.map +1 -0
- package/dist/headless/main.d.ts +2 -0
- package/dist/headless/main.js +133 -0
- package/dist/headless/main.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/providers.d.ts +2 -0
- package/dist/providers.js +3 -0
- package/dist/providers.js.map +1 -0
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +3 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui/artifact.d.ts +32 -0
- package/dist/ui/artifact.js +49 -0
- package/dist/ui/artifact.js.map +1 -0
- package/dist/ui/auth.d.ts +14 -0
- package/dist/ui/auth.js +177 -0
- package/dist/ui/auth.js.map +1 -0
- package/dist/ui/colors.d.ts +21 -0
- package/dist/ui/colors.js +28 -0
- package/dist/ui/colors.js.map +1 -0
- package/dist/ui/ink/app.d.ts +13 -0
- package/dist/ui/ink/app.js +418 -0
- package/dist/ui/ink/app.js.map +1 -0
- package/dist/ui/ink/logo.d.ts +6 -0
- package/dist/ui/ink/logo.js +20 -0
- package/dist/ui/ink/logo.js.map +1 -0
- package/dist/ui/ink/main.d.ts +2 -0
- package/dist/ui/ink/main.js +55 -0
- package/dist/ui/ink/main.js.map +1 -0
- package/dist/ui/ink/screen.d.ts +15 -0
- package/dist/ui/ink/screen.js +76 -0
- package/dist/ui/ink/screen.js.map +1 -0
- package/dist/ui/ink/select.d.ts +24 -0
- package/dist/ui/ink/select.js +74 -0
- package/dist/ui/ink/select.js.map +1 -0
- package/dist/ui/main.d.ts +2 -0
- package/dist/ui/main.js +1062 -0
- package/dist/ui/main.js.map +1 -0
- package/dist/ui/render.d.ts +15 -0
- package/dist/ui/render.js +130 -0
- package/dist/ui/render.js.map +1 -0
- package/dist/ui/select.d.ts +26 -0
- package/dist/ui/select.js +107 -0
- package/dist/ui/select.js.map +1 -0
- package/dist/ui/tui/app.d.ts +96 -0
- package/dist/ui/tui/app.js +476 -0
- package/dist/ui/tui/app.js.map +1 -0
- package/dist/ui/tui/editor.d.ts +40 -0
- package/dist/ui/tui/editor.js +124 -0
- package/dist/ui/tui/editor.js.map +1 -0
- package/dist/ui/tui/keys.d.ts +25 -0
- package/dist/ui/tui/keys.js +133 -0
- package/dist/ui/tui/keys.js.map +1 -0
- package/dist/ui/tui/layout.d.ts +63 -0
- package/dist/ui/tui/layout.js +202 -0
- package/dist/ui/tui/layout.js.map +1 -0
- package/dist/ui/tui/logo.d.ts +18 -0
- package/dist/ui/tui/logo.js +33 -0
- package/dist/ui/tui/logo.js.map +1 -0
- package/dist/ui/tui/screen.d.ts +48 -0
- package/dist/ui/tui/screen.js +150 -0
- package/dist/ui/tui/screen.js.map +1 -0
- package/dist/ui/tui/width.d.ts +30 -0
- package/dist/ui/tui/width.js +197 -0
- package/dist/ui/tui/width.js.map +1 -0
- package/dist/ui/wordmark.d.ts +30 -0
- package/dist/ui/wordmark.js +63 -0
- package/dist/ui/wordmark.js.map +1 -0
- package/package.json +49 -0
package/dist/ui/main.js
ADDED
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { errorMessage } from "@oharness/engine/core/errors.ts";
|
|
6
|
+
import { Harness } from "@oharness/engine/harness.ts";
|
|
7
|
+
import { MODE_DESCRIPTIONS, MODE_LABELS, PERMISSION_MODES, } from "@oharness/engine/agent/permissions.ts";
|
|
8
|
+
import { getTodos, renderTodos } from "@oharness/engine/tools/builtin/todo.ts";
|
|
9
|
+
import { printAuthStatus, runLogin, runLogout } from "./auth.js";
|
|
10
|
+
import { messageText } from "@oharness/engine/core/types.ts";
|
|
11
|
+
import { previewJson } from "@oharness/engine/util/json.ts";
|
|
12
|
+
import { c, newRenderState, renderEvent } from "./render.js";
|
|
13
|
+
import { select } from "./select.js";
|
|
14
|
+
/** The interactive readline, when there is one. Pickers need it to pause. */
|
|
15
|
+
let activeReadline;
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const args = { continueLast: false, print: false, help: false };
|
|
18
|
+
const rest = [];
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const arg = argv[i];
|
|
21
|
+
switch (arg) {
|
|
22
|
+
case "-m":
|
|
23
|
+
case "--model":
|
|
24
|
+
args.model = argv[++i];
|
|
25
|
+
break;
|
|
26
|
+
case "-r":
|
|
27
|
+
case "--resume":
|
|
28
|
+
args.resume = argv[++i];
|
|
29
|
+
break;
|
|
30
|
+
case "-c":
|
|
31
|
+
case "--continue":
|
|
32
|
+
args.continueLast = true;
|
|
33
|
+
break;
|
|
34
|
+
case "-p":
|
|
35
|
+
case "--print":
|
|
36
|
+
args.print = true;
|
|
37
|
+
break;
|
|
38
|
+
case "--permission-mode":
|
|
39
|
+
args.permissionMode = argv[++i];
|
|
40
|
+
break;
|
|
41
|
+
case "--dangerously-skip-permissions":
|
|
42
|
+
args.permissionMode = "bypassPermissions";
|
|
43
|
+
break;
|
|
44
|
+
case "-h":
|
|
45
|
+
case "--help":
|
|
46
|
+
args.help = true;
|
|
47
|
+
break;
|
|
48
|
+
default:
|
|
49
|
+
rest.push(arg);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (rest.length)
|
|
53
|
+
args.prompt = rest.join(" ");
|
|
54
|
+
return args;
|
|
55
|
+
}
|
|
56
|
+
const HELP = `oharness — a model-agnostic agent harness
|
|
57
|
+
|
|
58
|
+
Usage:
|
|
59
|
+
oharness [options] [prompt]
|
|
60
|
+
oharness auth <login|logout|list> [provider]
|
|
61
|
+
oharness schedule <list|run|daemon> [name]
|
|
62
|
+
oharness session <list|show|fork|rename|delete> [id]
|
|
63
|
+
|
|
64
|
+
Options:
|
|
65
|
+
-m, --model <id> Model alias or provider/model (default: from config)
|
|
66
|
+
-p, --print Run one prompt, print the result, and exit
|
|
67
|
+
-c, --continue Resume the most recent session in this directory
|
|
68
|
+
-r, --resume <id> Resume a specific session
|
|
69
|
+
--permission-mode <m> default | acceptEdits | plan | auto | bypassPermissions
|
|
70
|
+
--dangerously-skip-permissions
|
|
71
|
+
-h, --help Show this help
|
|
72
|
+
|
|
73
|
+
Slash commands (interactive):
|
|
74
|
+
/model [id] Show or switch the model mid-session
|
|
75
|
+
/models List registered models (/models refresh to re-query providers)
|
|
76
|
+
/login [prov] Sign in to a provider (API key or subscription)
|
|
77
|
+
/logout <prov> Remove stored credentials
|
|
78
|
+
/auth Show authentication status
|
|
79
|
+
/tools List available tools
|
|
80
|
+
/skills List installed skills
|
|
81
|
+
/agents List available subagents
|
|
82
|
+
/schedules List configured schedules
|
|
83
|
+
/plugins List active plugin bundles
|
|
84
|
+
/mcp Show MCP server status
|
|
85
|
+
/permissions Show rules; allow/deny/ask <pattern> [scope] to persist one
|
|
86
|
+
/mode [m] Pick the permission mode (no argument opens the picker)
|
|
87
|
+
/compact Summarize the conversation now
|
|
88
|
+
/todos Show the current task list
|
|
89
|
+
(attach an image by writing @path/to/shot.png in your message)
|
|
90
|
+
/cost Show token usage and estimated cost
|
|
91
|
+
/sessions List recent sessions
|
|
92
|
+
/sync [on|off] Whether this machine mirrors sessions to a sync server
|
|
93
|
+
/fork [n] Branch this session, optionally rewound to n messages
|
|
94
|
+
/rename <text> Title this session
|
|
95
|
+
/clear Start a fresh session
|
|
96
|
+
/exit Quit`;
|
|
97
|
+
async function main() {
|
|
98
|
+
const argv = process.argv.slice(2);
|
|
99
|
+
// `auth` is a subcommand, not a prompt — handle it before anything that
|
|
100
|
+
// would try to pick a model or open a session.
|
|
101
|
+
if (argv[0] === "auth") {
|
|
102
|
+
await runAuthCommand(argv.slice(1));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (argv[0] === "schedule") {
|
|
106
|
+
await runScheduleCommand(argv.slice(1));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (argv[0] === "session") {
|
|
110
|
+
await runSessionCommand(argv.slice(1));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const args = parseArgs(argv);
|
|
114
|
+
if (args.help) {
|
|
115
|
+
console.log(HELP);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const rl = args.print
|
|
119
|
+
? undefined
|
|
120
|
+
: createInterface({ input: process.stdin, output: process.stdout });
|
|
121
|
+
activeReadline = rl;
|
|
122
|
+
// Attach at creation, not at first use: readline starts consuming stdin the
|
|
123
|
+
// moment it exists, and `line` events with no listener are gone for good.
|
|
124
|
+
const readLine = rl ? makeLineReader(rl) : undefined;
|
|
125
|
+
// Indirection so the prompt can be replaced once the TUI exists: agents
|
|
126
|
+
// capture this closure, and it reads the current implementation per call.
|
|
127
|
+
let activePrompt = rl ? makePermissionPrompt(rl) : denyAll;
|
|
128
|
+
// The full-screen frontend owns the terminal, so engine diagnostics cannot
|
|
129
|
+
// go to stderr while it runs — they would paint over the frame. They are
|
|
130
|
+
// buffered until the transcript exists and then shown inside it. The
|
|
131
|
+
// line-based loop keeps stderr, which is what a pipeline expects.
|
|
132
|
+
const tui = useTui(args);
|
|
133
|
+
const pendingLogs = [];
|
|
134
|
+
let logLine = (line) => pendingLogs.push(line);
|
|
135
|
+
const harness = await Harness.create({
|
|
136
|
+
permissionPrompt: (request) => activePrompt(request),
|
|
137
|
+
...(tui ? { logSink: (line) => logLine(line) } : {}),
|
|
138
|
+
});
|
|
139
|
+
if (args.permissionMode)
|
|
140
|
+
harness.config.permissionMode = args.permissionMode;
|
|
141
|
+
let session = await openSession(harness, args);
|
|
142
|
+
let agent = harness.agent(session);
|
|
143
|
+
const runPrompt = async (rawPrompt) => {
|
|
144
|
+
const prompt = await attachImages(rawPrompt, harness.cwd);
|
|
145
|
+
const state = newRenderState();
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
const onSigint = () => {
|
|
148
|
+
controller.abort();
|
|
149
|
+
process.stdout.write(c.yellow("\n(interrupted)\n"));
|
|
150
|
+
};
|
|
151
|
+
process.on("SIGINT", onSigint);
|
|
152
|
+
try {
|
|
153
|
+
for await (const event of agent.run(prompt, { signal: controller.signal })) {
|
|
154
|
+
renderEvent(event, state);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
process.stdout.write(c.red(`\n✗ ${errorMessage(err)}\n`));
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
process.off("SIGINT", onSigint);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
if (args.print) {
|
|
165
|
+
if (!args.prompt) {
|
|
166
|
+
console.error("--print requires a prompt");
|
|
167
|
+
process.exitCode = 1;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
await runPrompt(args.prompt);
|
|
171
|
+
await agent.end();
|
|
172
|
+
await harness.close();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// A full-screen interface needs a terminal to own. Piped input, CI and dumb
|
|
176
|
+
// terminals fall through to the line-based loop below, which behaves the
|
|
177
|
+
// same and stays scriptable.
|
|
178
|
+
if (tui) {
|
|
179
|
+
rl?.close();
|
|
180
|
+
activeReadline = undefined;
|
|
181
|
+
await runTui(harness, session, agent, args, (prompt) => {
|
|
182
|
+
activePrompt = prompt;
|
|
183
|
+
}, (sink) => {
|
|
184
|
+
logLine = sink;
|
|
185
|
+
for (const line of pendingLogs.splice(0))
|
|
186
|
+
sink(line);
|
|
187
|
+
});
|
|
188
|
+
await harness.close();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
await printBanner(harness, session);
|
|
192
|
+
if (args.prompt)
|
|
193
|
+
await runPrompt(args.prompt);
|
|
194
|
+
while (true) {
|
|
195
|
+
const next = await readLine(c.cyan("\n› "));
|
|
196
|
+
if (next === undefined)
|
|
197
|
+
break; // stdin closed
|
|
198
|
+
const line = next.trim();
|
|
199
|
+
if (line === "")
|
|
200
|
+
continue;
|
|
201
|
+
if (line.startsWith("/")) {
|
|
202
|
+
const outcome = await handleSlashCommand(line, harness, session, agent);
|
|
203
|
+
if (outcome === "exit")
|
|
204
|
+
break;
|
|
205
|
+
if (outcome && typeof outcome === "object") {
|
|
206
|
+
if ("login" in outcome)
|
|
207
|
+
await runLogin(harness.auth, rl, outcome.login);
|
|
208
|
+
else {
|
|
209
|
+
session = outcome.session;
|
|
210
|
+
agent = outcome.agent;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
await runPrompt(line);
|
|
216
|
+
}
|
|
217
|
+
rl?.close();
|
|
218
|
+
await agent.end();
|
|
219
|
+
await harness.close();
|
|
220
|
+
}
|
|
221
|
+
async function openSession(harness, args) {
|
|
222
|
+
if (args.resume) {
|
|
223
|
+
const resumed = await harness.resumeSession(args.resume, args.model);
|
|
224
|
+
if (resumed)
|
|
225
|
+
return reportRepair(resumed);
|
|
226
|
+
console.error(c.yellow(`No session ${args.resume}; starting a new one.`));
|
|
227
|
+
}
|
|
228
|
+
else if (args.continueLast) {
|
|
229
|
+
const recent = await harness.store.mostRecent(harness.cwd);
|
|
230
|
+
if (recent) {
|
|
231
|
+
const resumed = await harness.resumeSession(recent.id, args.model);
|
|
232
|
+
if (resumed)
|
|
233
|
+
return reportRepair(resumed);
|
|
234
|
+
}
|
|
235
|
+
console.error(c.yellow("No previous session here; starting a new one."));
|
|
236
|
+
}
|
|
237
|
+
return harness.createSession({ model: args.model });
|
|
238
|
+
}
|
|
239
|
+
/** Say so when loading had to discard an interrupted turn. */
|
|
240
|
+
function reportRepair(session) {
|
|
241
|
+
if (session.droppedOnLoad > 0) {
|
|
242
|
+
const n = session.droppedOnLoad;
|
|
243
|
+
console.error(c.yellow(`Discarded ${n} unfinished message${n === 1 ? "" : "s"} from the end of this session — ` +
|
|
244
|
+
"a turn was interrupted while its tools were running."));
|
|
245
|
+
}
|
|
246
|
+
return session;
|
|
247
|
+
}
|
|
248
|
+
async function printBanner(harness, session) {
|
|
249
|
+
const mcp = harness.mcpStatus().filter((s) => s.connected);
|
|
250
|
+
const lines = [
|
|
251
|
+
c.bold("oharness"),
|
|
252
|
+
c.gray(`session ${session.id} · ${session.cwd}`),
|
|
253
|
+
c.gray(`model ${session.model.id} (${session.model.provider}) · ${harness.tools.visible().length} tools` +
|
|
254
|
+
(mcp.length ? ` · ${mcp.length} MCP server${mcp.length === 1 ? "" : "s"}` : "")),
|
|
255
|
+
c.gray("/help for commands"),
|
|
256
|
+
];
|
|
257
|
+
console.log(`${lines.join("\n")}\n`);
|
|
258
|
+
// The default model comes from catalog order, which need not match the
|
|
259
|
+
// credentials on this machine. Saying so now beats a 401 on the first
|
|
260
|
+
// message with no hint about which key is missing.
|
|
261
|
+
const authenticated = await harness.configuredProviders();
|
|
262
|
+
if (!authenticated.has(session.model.provider)) {
|
|
263
|
+
const entry = harness.providers.catalogEntry(session.model.provider);
|
|
264
|
+
const envVars = entry?.envVars.join(" or ");
|
|
265
|
+
console.log(c.yellow(`No credentials for ${session.model.provider}. ` +
|
|
266
|
+
(envVars ? `Set ${envVars}, ` : "") +
|
|
267
|
+
`run \`oharness auth login ${session.model.provider}\`, or pick another model with /model.`));
|
|
268
|
+
const usable = harness.listModels().filter((m) => authenticated.has(m.provider));
|
|
269
|
+
if (usable.length > 0) {
|
|
270
|
+
console.log(c.gray(`Available now: ${usable.slice(0, 6).map((m) => m.id).join(", ")}`));
|
|
271
|
+
}
|
|
272
|
+
console.log("");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function handleSlashCommand(line, harness, session, agent) {
|
|
276
|
+
const [command, ...rest] = line.slice(1).split(/\s+/);
|
|
277
|
+
const arg = rest.join(" ").trim();
|
|
278
|
+
switch (command) {
|
|
279
|
+
case "help":
|
|
280
|
+
console.log(HELP);
|
|
281
|
+
return undefined;
|
|
282
|
+
case "exit":
|
|
283
|
+
case "quit":
|
|
284
|
+
return "exit";
|
|
285
|
+
case "model": {
|
|
286
|
+
if (!arg) {
|
|
287
|
+
console.log(`${session.model.id} — ${session.model.displayName} (${session.model.provider})`);
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
// Route through the agent so `model_changed` is emitted rather than
|
|
292
|
+
// silently mutating the session.
|
|
293
|
+
for await (const event of agent.switchModel(arg)) {
|
|
294
|
+
if (event.type === "model_changed") {
|
|
295
|
+
console.log(c.green(`⇄ ${event.from} → ${event.to}`));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
console.log(c.red(errorMessage(err)));
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
case "login": {
|
|
305
|
+
// handled by the caller, which owns the readline instance
|
|
306
|
+
return { login: arg || undefined };
|
|
307
|
+
}
|
|
308
|
+
case "logout": {
|
|
309
|
+
if (!arg) {
|
|
310
|
+
console.log("Usage: /logout <provider>");
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
await runLogout(harness.auth, arg);
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
case "skills": {
|
|
317
|
+
const skills = harness.skills.list();
|
|
318
|
+
if (skills.length === 0) {
|
|
319
|
+
console.log(c.gray("No skills installed."));
|
|
320
|
+
console.log(c.gray("Add one at .oharness/skills/<name>/SKILL.md"));
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
for (const skill of skills) {
|
|
324
|
+
console.log(`${c.bold(skill.name.padEnd(20))} ${c.gray(skill.source)}`);
|
|
325
|
+
console.log(` ${skill.description}`);
|
|
326
|
+
}
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
case "agents": {
|
|
330
|
+
const agents = harness.subagents.list();
|
|
331
|
+
if (agents.length === 0) {
|
|
332
|
+
console.log(c.gray("No subagents configured."));
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
for (const definition of agents) {
|
|
336
|
+
const model = definition.model ? c.gray(` [${definition.model}]`) : "";
|
|
337
|
+
console.log(`${c.bold(definition.name.padEnd(20))}${model}`);
|
|
338
|
+
console.log(` ${definition.description}`);
|
|
339
|
+
}
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
case "plugins": {
|
|
343
|
+
if (harness.plugins.length === 0) {
|
|
344
|
+
console.log(c.gray("No plugins active. Add one at .oharness/plugins/<name>/oharness.plugin.json"));
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
for (const plugin of harness.plugins) {
|
|
348
|
+
const version = plugin.version ? c.gray(` v${plugin.version}`) : "";
|
|
349
|
+
console.log(`${c.bold(plugin.name)}${version} ${c.gray(`(${plugin.source})`)}`);
|
|
350
|
+
if (plugin.description)
|
|
351
|
+
console.log(` ${plugin.description}`);
|
|
352
|
+
}
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
case "fork": {
|
|
356
|
+
// Persist first: an unsaved turn would not be carried into the branch.
|
|
357
|
+
await session.persist();
|
|
358
|
+
const at = arg ? Number(arg) : undefined;
|
|
359
|
+
if (arg && !Number.isInteger(at)) {
|
|
360
|
+
console.log(c.red("Usage: /fork [message-count]"));
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
const forked = await harness.forkSession(session.id, {
|
|
364
|
+
...(at !== undefined ? { keepMessages: at } : {}),
|
|
365
|
+
});
|
|
366
|
+
if (!forked) {
|
|
367
|
+
console.log(c.red("could not fork this session"));
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
console.log(c.green(`⑂ forked to ${forked.id}`) +
|
|
371
|
+
c.gray(` — ${forked.messages.length} messages, now working on the branch`));
|
|
372
|
+
console.log(c.gray(` the original is intact: oharness --resume ${session.id}`));
|
|
373
|
+
return { session: forked, agent: harness.agent(forked) };
|
|
374
|
+
}
|
|
375
|
+
case "rename": {
|
|
376
|
+
if (!arg) {
|
|
377
|
+
console.log("Usage: /rename <title>");
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
session.title = arg;
|
|
381
|
+
await session.persist();
|
|
382
|
+
console.log(c.green(`✓ renamed to "${arg}"`));
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
case "schedules": {
|
|
386
|
+
const status = await harness.scheduler.status();
|
|
387
|
+
if (status.length === 0) {
|
|
388
|
+
console.log(c.gray("No schedules configured."));
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
for (const entry of status) {
|
|
392
|
+
const next = entry.nextRunAt
|
|
393
|
+
? new Date(entry.nextRunAt).toISOString().slice(0, 16).replace("T", " ")
|
|
394
|
+
: "disabled";
|
|
395
|
+
console.log(`${entry.enabled ? c.green("●") : c.gray("○")} ${entry.name.padEnd(18)} ${c.gray(entry.cron.padEnd(16))} ${c.gray(next)}`);
|
|
396
|
+
}
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
case "auth": {
|
|
400
|
+
await printAuthStatus(harness.auth);
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
case "models": {
|
|
404
|
+
if (arg === "refresh") {
|
|
405
|
+
const added = await harness.refreshModels();
|
|
406
|
+
if (added.length === 0) {
|
|
407
|
+
console.log(c.gray("No provider reported a model list."));
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
const byProvider = new Map();
|
|
411
|
+
for (const model of added) {
|
|
412
|
+
byProvider.set(model.provider, (byProvider.get(model.provider) ?? 0) + 1);
|
|
413
|
+
}
|
|
414
|
+
const summary = [...byProvider].map(([id, n]) => `${id} ${n}`).join(", ");
|
|
415
|
+
console.log(c.green(`Discovered ${added.length} models (${summary}).`));
|
|
416
|
+
console.log(c.gray("Saved to the user catalog for next time."));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const authenticated = await harness.configuredProviders();
|
|
420
|
+
// Hundreds of entries now that the catalog is seeded from pi-ai's own,
|
|
421
|
+
// and a terminal has no folding. The ones that can answer are printed;
|
|
422
|
+
// the rest are counted, which is the part that was actually in question.
|
|
423
|
+
const idle = new Map();
|
|
424
|
+
for (const model of harness.listModels()) {
|
|
425
|
+
if (!authenticated.has(model.provider)) {
|
|
426
|
+
idle.set(model.provider, (idle.get(model.provider) ?? 0) + 1);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const mark = model.id === session.model.id ? c.green("●") : " ";
|
|
430
|
+
const context = `${Math.round(model.contextWindow / 1000)}k ctx`;
|
|
431
|
+
console.log(`${mark} ${model.id.padEnd(12)} ${c.gray(`${model.provider}/${model.model} ${context}`)}`);
|
|
432
|
+
}
|
|
433
|
+
for (const [provider, count] of idle) {
|
|
434
|
+
console.log(`${c.gray("○")} ${c.gray(`${provider.padEnd(12)} ${count} model${count === 1 ? "" : "s"}, no credentials`)}`);
|
|
435
|
+
}
|
|
436
|
+
console.log(c.gray("\n○ = not usable yet; run `oharness auth login <provider>`"));
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
case "tools": {
|
|
440
|
+
const grouped = new Map();
|
|
441
|
+
for (const tool of harness.tools.visible(harness.config.tools)) {
|
|
442
|
+
const key = tool.source === "mcp" ? `mcp:${tool.server}` : "builtin";
|
|
443
|
+
grouped.set(key, [...(grouped.get(key) ?? []), tool.name]);
|
|
444
|
+
}
|
|
445
|
+
for (const [group, names] of grouped) {
|
|
446
|
+
console.log(`${c.bold(group)}: ${names.join(", ")}`);
|
|
447
|
+
}
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
case "mcp": {
|
|
451
|
+
const statuses = harness.mcpStatus();
|
|
452
|
+
if (statuses.length === 0) {
|
|
453
|
+
console.log(c.gray("No MCP servers configured. Add them under `mcpServers` in the config."));
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
for (const status of statuses) {
|
|
457
|
+
const mark = status.connected ? c.green("✓") : c.red("✗");
|
|
458
|
+
const detail = status.error
|
|
459
|
+
? c.red(status.error)
|
|
460
|
+
: c.gray(`${status.transport}, ${status.toolCount} tools`);
|
|
461
|
+
console.log(`${mark} ${status.name.padEnd(16)} ${detail}`);
|
|
462
|
+
}
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
case "permissions": {
|
|
466
|
+
const [action, ...words] = arg ? arg.split(/\s+/) : [];
|
|
467
|
+
// `/permissions allow Bash(npm test) project` — the trailing scope is
|
|
468
|
+
// optional and defaults to the project, which is where a rule about
|
|
469
|
+
// this repo belongs.
|
|
470
|
+
if (action === "allow" || action === "deny" || action === "ask") {
|
|
471
|
+
const scopes = ["user", "project", "local"];
|
|
472
|
+
const last = words.at(-1);
|
|
473
|
+
const scope = scopes.includes(last)
|
|
474
|
+
? words.pop()
|
|
475
|
+
: "project";
|
|
476
|
+
const pattern = words.join(" ");
|
|
477
|
+
if (!pattern) {
|
|
478
|
+
console.log(`Usage: /permissions ${action} <pattern> [${scopes.join("|")}]`);
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
await harness.settings.addRule(scope, action, pattern);
|
|
482
|
+
session.permissions.addRule(action, pattern);
|
|
483
|
+
console.log(c.magenta(`${action} ${pattern} → ${scope}`));
|
|
484
|
+
console.log(c.gray(harness.settings.pathFor(scope)));
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
if (action === "remove") {
|
|
488
|
+
const scopes = ["user", "project", "local"];
|
|
489
|
+
const kind = words.shift();
|
|
490
|
+
if (kind !== "allow" && kind !== "deny" && kind !== "ask") {
|
|
491
|
+
console.log("Usage: /permissions remove <allow|deny|ask> <pattern> [scope]");
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
const last = words.at(-1);
|
|
495
|
+
const scope = scopes.includes(last)
|
|
496
|
+
? words.pop()
|
|
497
|
+
: "project";
|
|
498
|
+
const pattern = words.join(" ");
|
|
499
|
+
const removed = await harness.settings.removeRule(scope, kind, pattern);
|
|
500
|
+
console.log(removed
|
|
501
|
+
? c.magenta(`removed ${kind} ${pattern} from ${scope}`)
|
|
502
|
+
: c.gray(`no such rule in ${scope}`));
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
if (action && action !== "show") {
|
|
506
|
+
console.log("Usage: /permissions [show|allow|deny|ask|remove] ...");
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
const rules = session.permissions.listRules();
|
|
510
|
+
console.log(`mode: ${c.bold(session.permissions.getMode())}`);
|
|
511
|
+
console.log(`writes: ${c.bold(harness.workspace.mode)} ${c.gray(harness.workspace.mode === "workspace-write"
|
|
512
|
+
? `(${harness.workspace.writableRoots.join(", ")})`
|
|
513
|
+
: "")}`);
|
|
514
|
+
for (const kind of ["allow", "ask", "deny"]) {
|
|
515
|
+
if (rules[kind].length)
|
|
516
|
+
console.log(`${kind}: ${rules[kind].join(", ")}`);
|
|
517
|
+
}
|
|
518
|
+
if (rules.sessionAllow.length) {
|
|
519
|
+
console.log(c.gray(`approved this session: ${rules.sessionAllow.join(", ")}`));
|
|
520
|
+
}
|
|
521
|
+
// Where a rule would land, so an edit does not have to be guessed at.
|
|
522
|
+
for (const scope of ["user", "project", "local"]) {
|
|
523
|
+
console.log(c.gray(`${scope.padEnd(8)} ${harness.settings.pathFor(scope)}`));
|
|
524
|
+
}
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
case "mode": {
|
|
528
|
+
// With no argument this opens the picker; naming a mode still works, so
|
|
529
|
+
// scripts and muscle memory keep going.
|
|
530
|
+
if (!arg) {
|
|
531
|
+
const rl = activeReadline;
|
|
532
|
+
if (!rl) {
|
|
533
|
+
console.log(`Usage: /mode <${PERMISSION_MODES.join("|")}>`);
|
|
534
|
+
return undefined;
|
|
535
|
+
}
|
|
536
|
+
const picked = await pickMode(rl, session.permissions.getMode());
|
|
537
|
+
if (picked) {
|
|
538
|
+
session.permissions.setMode(picked);
|
|
539
|
+
console.log(c.magenta(`mode → ${MODE_LABELS[picked]}`));
|
|
540
|
+
}
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
const named = PERMISSION_MODES.find((mode) => mode === arg || MODE_LABELS[mode].toLowerCase() === arg.toLowerCase());
|
|
544
|
+
if (!named) {
|
|
545
|
+
console.log(`Usage: /mode <${PERMISSION_MODES.join("|")}>`);
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
session.permissions.setMode(named);
|
|
549
|
+
console.log(c.magenta(`mode → ${MODE_LABELS[named]}`));
|
|
550
|
+
return undefined;
|
|
551
|
+
}
|
|
552
|
+
case "compact": {
|
|
553
|
+
const { compact } = await import("@oharness/engine/agent/compaction.ts");
|
|
554
|
+
const provider = harness.providers.getProvider(session.model.provider);
|
|
555
|
+
try {
|
|
556
|
+
const result = await compact(session.messages, provider, session.model, {
|
|
557
|
+
// Manual compaction is a deliberate act; keep less of the tail.
|
|
558
|
+
keepTailFraction: 0.15,
|
|
559
|
+
});
|
|
560
|
+
if (result.droppedMessages === 0) {
|
|
561
|
+
console.log(c.yellow("Nothing safe to compact yet."));
|
|
562
|
+
return undefined;
|
|
563
|
+
}
|
|
564
|
+
await session.replaceMessages(result.messages);
|
|
565
|
+
console.log(c.yellow(`⟳ Compacted ${result.droppedMessages} messages (~${result.tokensBefore} → ~${result.tokensAfter} tokens)`));
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
console.log(c.red(`Compaction failed: ${errorMessage(err)}`));
|
|
569
|
+
}
|
|
570
|
+
return undefined;
|
|
571
|
+
}
|
|
572
|
+
case "todos": {
|
|
573
|
+
console.log(renderTodos(getTodos(session.state)));
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
case "cost": {
|
|
577
|
+
const u = session.usage;
|
|
578
|
+
console.log([
|
|
579
|
+
`input ${u.inputTokens}`,
|
|
580
|
+
`output ${u.outputTokens}`,
|
|
581
|
+
`cached ${u.cacheReadTokens ?? 0} read / ${u.cacheWriteTokens ?? 0} written`,
|
|
582
|
+
`turns ${session.turns}`,
|
|
583
|
+
`cost ${session.costUsd > 0 ? `$${session.costUsd.toFixed(4)}` : "n/a (model has no pricing)"}`,
|
|
584
|
+
].join("\n"));
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
case "sync": {
|
|
588
|
+
const wanted = arg.toLowerCase();
|
|
589
|
+
if (wanted && wanted !== "on" && wanted !== "off") {
|
|
590
|
+
console.log("Usage: /sync [on|off]");
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
if (wanted) {
|
|
594
|
+
await harness.setSyncEnabled(wanted === "on");
|
|
595
|
+
}
|
|
596
|
+
const status = harness.syncStatus();
|
|
597
|
+
if (status.on) {
|
|
598
|
+
console.log(`${c.green("sync on")} — mirroring to ${status.url}`);
|
|
599
|
+
console.log(c.gray("Transcripts for this project leave this machine. /sync off to stop."));
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
// Each reason needs different advice, which is why the status carries
|
|
603
|
+
// one rather than a bare boolean.
|
|
604
|
+
const why = {
|
|
605
|
+
disabled: "Switched off. /sync on to resume.",
|
|
606
|
+
"no-credential": "Not signed in, so there is nowhere to send. /login official",
|
|
607
|
+
excluded: "This project is excluded by config; other projects still sync.",
|
|
608
|
+
};
|
|
609
|
+
console.log(`${c.yellow("sync off")} — ${why[status.reason ?? ""] ?? status.reason}`);
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
case "sessions": {
|
|
613
|
+
const sessions = await harness.listSessions(10);
|
|
614
|
+
if (sessions.length === 0) {
|
|
615
|
+
console.log(c.gray("No saved sessions."));
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
618
|
+
for (const meta of sessions) {
|
|
619
|
+
const when = new Date(meta.updatedAt).toISOString().slice(0, 16).replace("T", " ");
|
|
620
|
+
console.log(`${meta.id} ${c.gray(`${when} ${meta.model} ${meta.turns} turns ${meta.cwd}`)}`);
|
|
621
|
+
}
|
|
622
|
+
console.log(c.gray("\nResume with: oharness --resume <id>"));
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
case "clear": {
|
|
626
|
+
const fresh = harness.createSession({ model: session.model.id });
|
|
627
|
+
console.log(c.gray(`New session ${fresh.id}`));
|
|
628
|
+
return { session: fresh, agent: harness.agent(fresh) };
|
|
629
|
+
}
|
|
630
|
+
default:
|
|
631
|
+
console.log(c.yellow(`Unknown command /${command}. Try /help.`));
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async function runAuthCommand(argv) {
|
|
636
|
+
const [sub, provider] = argv;
|
|
637
|
+
const harness = await Harness.create({ ephemeral: true });
|
|
638
|
+
try {
|
|
639
|
+
switch (sub) {
|
|
640
|
+
case "login": {
|
|
641
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
642
|
+
try {
|
|
643
|
+
await runLogin(harness.auth, rl, provider);
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
rl.close();
|
|
647
|
+
}
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
case "logout": {
|
|
651
|
+
if (!provider) {
|
|
652
|
+
console.error("Usage: oharness auth logout <provider>");
|
|
653
|
+
process.exitCode = 1;
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
await runLogout(harness.auth, provider);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
case "list":
|
|
660
|
+
case undefined:
|
|
661
|
+
await printAuthStatus(harness.auth);
|
|
662
|
+
return;
|
|
663
|
+
default:
|
|
664
|
+
console.error(`Unknown auth command: ${sub}`);
|
|
665
|
+
process.exitCode = 1;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
finally {
|
|
669
|
+
await harness.close();
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const IMAGE_TYPES = {
|
|
673
|
+
".png": "image/png",
|
|
674
|
+
".jpg": "image/jpeg",
|
|
675
|
+
".jpeg": "image/jpeg",
|
|
676
|
+
".gif": "image/gif",
|
|
677
|
+
".webp": "image/webp",
|
|
678
|
+
};
|
|
679
|
+
/**
|
|
680
|
+
* Turn `@path/to/shot.png` references in a prompt into image blocks.
|
|
681
|
+
*
|
|
682
|
+
* The whole data path — ImageBlock through all three protocols — already
|
|
683
|
+
* worked; there was simply no way to get an image in from the terminal.
|
|
684
|
+
*/
|
|
685
|
+
async function attachImages(prompt, cwd) {
|
|
686
|
+
const refs = [...prompt.matchAll(/@(\S+)/g)]
|
|
687
|
+
.map((match) => match[1])
|
|
688
|
+
.filter((ref) => IMAGE_TYPES[path.extname(ref).toLowerCase()]);
|
|
689
|
+
if (refs.length === 0)
|
|
690
|
+
return prompt;
|
|
691
|
+
const content = [];
|
|
692
|
+
let text = prompt;
|
|
693
|
+
for (const ref of refs) {
|
|
694
|
+
const file = path.resolve(cwd, ref);
|
|
695
|
+
try {
|
|
696
|
+
const data = await readFile(file);
|
|
697
|
+
content.push({
|
|
698
|
+
type: "image",
|
|
699
|
+
mimeType: IMAGE_TYPES[path.extname(ref).toLowerCase()],
|
|
700
|
+
data: data.toString("base64"),
|
|
701
|
+
});
|
|
702
|
+
// Leave a readable placeholder so the model knows which file is which.
|
|
703
|
+
text = text.replace(`@${ref}`, `[image: ${path.basename(ref)}]`);
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
// An unreadable path is more likely a literal @mention than an image.
|
|
707
|
+
console.log(c.yellow(`could not read ${ref}; leaving it as text`));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (content.length === 0)
|
|
711
|
+
return prompt;
|
|
712
|
+
return [{ role: "user", content: [{ type: "text", text }, ...content] }];
|
|
713
|
+
}
|
|
714
|
+
async function runSessionCommand(argv) {
|
|
715
|
+
const [sub, id, ...rest] = argv;
|
|
716
|
+
const harness = await Harness.create({ ephemeral: true });
|
|
717
|
+
try {
|
|
718
|
+
switch (sub) {
|
|
719
|
+
case "list":
|
|
720
|
+
case undefined: {
|
|
721
|
+
const sessions = await harness.listSessions(30);
|
|
722
|
+
if (sessions.length === 0) {
|
|
723
|
+
console.log(c.gray("No sessions yet."));
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
for (const meta of sessions) {
|
|
727
|
+
const when = new Date(meta.updatedAt).toISOString().slice(0, 16).replace("T", " ");
|
|
728
|
+
const cost = meta.costUsd > 0 ? ` $${meta.costUsd.toFixed(4)}` : "";
|
|
729
|
+
const lineage = meta.forkedFrom ? c.gray(` ← ${meta.forkedFrom}`) : "";
|
|
730
|
+
console.log(`${c.bold(meta.id)} ${c.gray(when)} ${meta.model} ${meta.turns} turns${c.gray(cost)}${lineage}`);
|
|
731
|
+
if (meta.title)
|
|
732
|
+
console.log(c.gray(` ${meta.title}`));
|
|
733
|
+
}
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
case "show": {
|
|
737
|
+
if (!id)
|
|
738
|
+
return usage("session show <id>");
|
|
739
|
+
const record = await harness.store.load(id);
|
|
740
|
+
if (!record)
|
|
741
|
+
return notFound(id);
|
|
742
|
+
console.log(c.bold(record.meta.id));
|
|
743
|
+
console.log(c.gray(`${record.meta.model} · ${record.meta.turns} turns · ${record.messages.length} messages`));
|
|
744
|
+
if (record.meta.forkedFrom) {
|
|
745
|
+
console.log(c.gray(`forked from ${record.meta.forkedFrom} at message ${record.meta.forkedAt}`));
|
|
746
|
+
}
|
|
747
|
+
console.log("");
|
|
748
|
+
record.messages.forEach((message, index) => {
|
|
749
|
+
const role = message.role === "user" ? c.cyan("user") : c.green("assistant");
|
|
750
|
+
const calls = message.content.filter((b) => b.type === "tool_call").length;
|
|
751
|
+
const results = message.content.filter((b) => b.type === "tool_result").length;
|
|
752
|
+
const marks = [
|
|
753
|
+
calls ? `${calls} tool call${calls === 1 ? "" : "s"}` : "",
|
|
754
|
+
results ? `${results} result${results === 1 ? "" : "s"}` : "",
|
|
755
|
+
].filter(Boolean).join(", ");
|
|
756
|
+
const text = previewJson(messageText(message).replace(/\s+/g, " "), 90);
|
|
757
|
+
console.log(`${c.gray(String(index).padStart(3))} ${role} ${text}${marks ? c.gray(` [${marks}]`) : ""}`);
|
|
758
|
+
});
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
case "fork": {
|
|
762
|
+
if (!id)
|
|
763
|
+
return usage("session fork <id> [--at <n>] [--title <text>]");
|
|
764
|
+
const at = flagValue(rest, "--at");
|
|
765
|
+
const title = flagValue(rest, "--title");
|
|
766
|
+
const forked = await harness.forkSession(id, {
|
|
767
|
+
...(at !== undefined ? { keepMessages: Number(at) } : {}),
|
|
768
|
+
...(title !== undefined ? { title } : {}),
|
|
769
|
+
});
|
|
770
|
+
if (!forked)
|
|
771
|
+
return notFound(id);
|
|
772
|
+
console.log(c.green(`✓ forked ${id} → ${forked.id}`));
|
|
773
|
+
console.log(c.gray(` ${forked.messages.length} messages carried over`));
|
|
774
|
+
console.log(c.gray(` resume it with: oharness --resume ${forked.id}`));
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
case "rename": {
|
|
778
|
+
const title = rest.join(" ").trim();
|
|
779
|
+
if (!id || !title)
|
|
780
|
+
return usage("session rename <id> <title>");
|
|
781
|
+
console.log((await harness.renameSession(id, title))
|
|
782
|
+
? c.green(`✓ renamed ${id}`)
|
|
783
|
+
: c.yellow(`no session ${id}`));
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
case "delete": {
|
|
787
|
+
if (!id)
|
|
788
|
+
return usage("session delete <id>");
|
|
789
|
+
const children = await harness.store.children(id);
|
|
790
|
+
if (children.length > 0) {
|
|
791
|
+
// Deleting a parent orphans its branches; say so rather than
|
|
792
|
+
// silently breaking the lineage they record.
|
|
793
|
+
console.log(c.yellow(`${id} has ${children.length} fork${children.length === 1 ? "" : "s"} (${children.map((m) => m.id).join(", ")}); they will lose their parent.`));
|
|
794
|
+
}
|
|
795
|
+
console.log((await harness.deleteSession(id))
|
|
796
|
+
? c.green(`✓ deleted ${id}`)
|
|
797
|
+
: c.yellow(`no session ${id}`));
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
default:
|
|
801
|
+
console.error(`Unknown session command: ${sub}`);
|
|
802
|
+
process.exitCode = 1;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
finally {
|
|
806
|
+
await harness.close();
|
|
807
|
+
}
|
|
808
|
+
function usage(line) {
|
|
809
|
+
console.error(`Usage: oharness ${line}`);
|
|
810
|
+
process.exitCode = 1;
|
|
811
|
+
}
|
|
812
|
+
function notFound(target) {
|
|
813
|
+
console.error(`No session ${target}`);
|
|
814
|
+
process.exitCode = 1;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function flagValue(argv, flag) {
|
|
818
|
+
const index = argv.indexOf(flag);
|
|
819
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
820
|
+
}
|
|
821
|
+
async function runScheduleCommand(argv) {
|
|
822
|
+
const [sub, name] = argv;
|
|
823
|
+
const harness = await Harness.create();
|
|
824
|
+
try {
|
|
825
|
+
switch (sub) {
|
|
826
|
+
case "list":
|
|
827
|
+
case undefined: {
|
|
828
|
+
const status = await harness.scheduler.status();
|
|
829
|
+
if (status.length === 0) {
|
|
830
|
+
console.log(c.gray("No schedules configured."));
|
|
831
|
+
console.log(c.gray('Add them under "schedules" in oharness.config.json.'));
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
for (const entry of status) {
|
|
835
|
+
const mark = entry.enabled ? c.green("●") : c.gray("○");
|
|
836
|
+
const next = entry.nextRunAt
|
|
837
|
+
? c.gray(`next ${new Date(entry.nextRunAt).toISOString().slice(0, 16).replace("T", " ")}`)
|
|
838
|
+
: c.gray("disabled");
|
|
839
|
+
console.log(`${mark} ${c.bold(entry.name.padEnd(18))} ${entry.cron.padEnd(16)} ${next}`);
|
|
840
|
+
if (entry.lastRun) {
|
|
841
|
+
const when = new Date(entry.lastRun.startedAt).toISOString().slice(0, 16).replace("T", " ");
|
|
842
|
+
const outcome = entry.lastRun.ok ? c.green("ok") : c.red(entry.lastRun.error ?? "failed");
|
|
843
|
+
console.log(c.gray(` last ${when} · ${outcome}`));
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
case "run": {
|
|
849
|
+
if (!name) {
|
|
850
|
+
console.error("Usage: oharness schedule run <name>");
|
|
851
|
+
process.exitCode = 1;
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
console.log(c.gray(`running ${name}…`));
|
|
855
|
+
const run = await harness.scheduler.runNow(name);
|
|
856
|
+
console.log(run.ok
|
|
857
|
+
? c.green(`✓ ${name} finished in ${run.turns} turns ($${run.costUsd.toFixed(4)})`)
|
|
858
|
+
: c.red(`✗ ${name}: ${run.error}`));
|
|
859
|
+
if (run.output)
|
|
860
|
+
console.log(`\n${run.output}`);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
case "daemon": {
|
|
864
|
+
const status = await harness.scheduler.status();
|
|
865
|
+
const enabled = status.filter((entry) => entry.enabled);
|
|
866
|
+
if (enabled.length === 0) {
|
|
867
|
+
console.error("No enabled schedules; nothing to run.");
|
|
868
|
+
process.exitCode = 1;
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
console.log(c.bold(`Watching ${enabled.length} schedule${enabled.length === 1 ? "" : "s"}.`));
|
|
872
|
+
for (const entry of enabled)
|
|
873
|
+
console.log(c.gray(` ${entry.name} ${entry.cron}`));
|
|
874
|
+
console.log(c.gray("Ctrl-C to stop.\n"));
|
|
875
|
+
harness.scheduler.start();
|
|
876
|
+
// Hold the process open: the scheduler's own timer is unref'd so it
|
|
877
|
+
// never keeps a short-lived CLI invocation alive by itself.
|
|
878
|
+
await new Promise((resolve) => {
|
|
879
|
+
const stop = () => {
|
|
880
|
+
console.log(c.yellow("\nstopping…"));
|
|
881
|
+
harness.scheduler.stop();
|
|
882
|
+
resolve();
|
|
883
|
+
};
|
|
884
|
+
process.on("SIGINT", stop);
|
|
885
|
+
process.on("SIGTERM", stop);
|
|
886
|
+
});
|
|
887
|
+
await harness.scheduler.drain();
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
default:
|
|
891
|
+
console.error(`Unknown schedule command: ${sub}`);
|
|
892
|
+
process.exitCode = 1;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
finally {
|
|
896
|
+
await harness.close();
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function makePermissionPrompt(rl) {
|
|
900
|
+
return async (request) => {
|
|
901
|
+
const detail = [previewJson(request.input, 200)];
|
|
902
|
+
if (request.reason)
|
|
903
|
+
detail.unshift(request.reason);
|
|
904
|
+
const choice = await select({
|
|
905
|
+
title: `${c.yellow("?")} ${request.title}`,
|
|
906
|
+
detail,
|
|
907
|
+
rl,
|
|
908
|
+
choices: [
|
|
909
|
+
{ value: { allow: true }, label: "Yes", hint: "just this once" },
|
|
910
|
+
{
|
|
911
|
+
value: { allow: true, remember: "session" },
|
|
912
|
+
label: "Yes, and don't ask again this session",
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
value: { allow: true, remember: "project" },
|
|
916
|
+
label: "Yes, and remember for this project",
|
|
917
|
+
hint: ".oharness/settings.json",
|
|
918
|
+
},
|
|
919
|
+
{ value: { allow: false }, label: "No", hint: "tell the model what to do instead" },
|
|
920
|
+
],
|
|
921
|
+
});
|
|
922
|
+
// Cancelling is a denial. Falling through to the highlighted option would
|
|
923
|
+
// approve something on a stray Ctrl-C.
|
|
924
|
+
return choice ?? { allow: false };
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
/** The mode picker behind `/mode` with no argument. */
|
|
928
|
+
async function pickMode(rl, current) {
|
|
929
|
+
return select({
|
|
930
|
+
title: "Mode",
|
|
931
|
+
rl,
|
|
932
|
+
choices: PERMISSION_MODES.map((mode) => ({
|
|
933
|
+
value: mode,
|
|
934
|
+
label: MODE_LABELS[mode],
|
|
935
|
+
hint: MODE_DESCRIPTIONS[mode],
|
|
936
|
+
current: mode === current,
|
|
937
|
+
})),
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
/** Non-interactive runs cannot ask, so anything needing approval is denied. */
|
|
941
|
+
async function denyAll() {
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
main().catch((err) => {
|
|
945
|
+
console.error(c.red(`fatal: ${errorMessage(err)}`));
|
|
946
|
+
process.exitCode = 1;
|
|
947
|
+
});
|
|
948
|
+
/**
|
|
949
|
+
* Read user lines, queueing anything that arrives before we ask for it.
|
|
950
|
+
*
|
|
951
|
+
* `rl.question()` only sees input delivered after the call, so lines piped in
|
|
952
|
+
* ahead of the first prompt were dropped and the process exited 0 as if the
|
|
953
|
+
* user had typed nothing. Listening from startup makes
|
|
954
|
+
* `printf '/tools\n/exit\n' | oharness` behave the same as typing it.
|
|
955
|
+
*/
|
|
956
|
+
function makeLineReader(rl) {
|
|
957
|
+
const queued = [];
|
|
958
|
+
let waiting;
|
|
959
|
+
let closed = false;
|
|
960
|
+
const deliver = (line) => {
|
|
961
|
+
if (!waiting)
|
|
962
|
+
return false;
|
|
963
|
+
const resolve = waiting;
|
|
964
|
+
waiting = undefined;
|
|
965
|
+
resolve(line);
|
|
966
|
+
return true;
|
|
967
|
+
};
|
|
968
|
+
rl.on("line", (line) => {
|
|
969
|
+
if (!deliver(line))
|
|
970
|
+
queued.push(line);
|
|
971
|
+
});
|
|
972
|
+
rl.on("close", () => {
|
|
973
|
+
closed = true;
|
|
974
|
+
deliver(undefined);
|
|
975
|
+
});
|
|
976
|
+
return async (prompt) => {
|
|
977
|
+
const buffered = queued.shift();
|
|
978
|
+
if (buffered !== undefined) {
|
|
979
|
+
// A pipe does not echo, so print what we are acting on; a terminal has
|
|
980
|
+
// already shown it and echoing again would double every line.
|
|
981
|
+
process.stdout.write(process.stdin.isTTY ? prompt : `${prompt}${buffered}\n`);
|
|
982
|
+
return buffered;
|
|
983
|
+
}
|
|
984
|
+
if (closed)
|
|
985
|
+
return undefined;
|
|
986
|
+
process.stdout.write(prompt);
|
|
987
|
+
return new Promise((resolve) => {
|
|
988
|
+
waiting = resolve;
|
|
989
|
+
});
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Whether to take over the screen.
|
|
994
|
+
*
|
|
995
|
+
* Both streams must be a terminal: stdout alone is not enough, because raw
|
|
996
|
+
* mode needs a tty on stdin, and a piped stdin with a tty stdout would leave
|
|
997
|
+
* the app waiting for keys that arrive as whole lines.
|
|
998
|
+
*/
|
|
999
|
+
function useTui(args) {
|
|
1000
|
+
if (process.env["OHARNESS_NO_TUI"])
|
|
1001
|
+
return false;
|
|
1002
|
+
if (args.print)
|
|
1003
|
+
return false;
|
|
1004
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1005
|
+
}
|
|
1006
|
+
async function runTui(harness, session, agent, args, installPrompt, installLogSink) {
|
|
1007
|
+
const { Tui } = await import("./tui/app.js");
|
|
1008
|
+
const banner = [
|
|
1009
|
+
`session ${session.id} · ${session.cwd}`,
|
|
1010
|
+
`model ${session.model.id} (${session.model.provider}) · ${harness.tools.list().length} tools`,
|
|
1011
|
+
"/help for commands · Ctrl-C interrupts · Ctrl-D quits",
|
|
1012
|
+
];
|
|
1013
|
+
const authenticated = await harness.configuredProviders();
|
|
1014
|
+
if (!authenticated.has(session.model.provider)) {
|
|
1015
|
+
banner.push(`No credentials for ${session.model.provider}. Run /login, or pick another model with /model.`);
|
|
1016
|
+
}
|
|
1017
|
+
const app = new Tui({
|
|
1018
|
+
harness,
|
|
1019
|
+
session,
|
|
1020
|
+
agent,
|
|
1021
|
+
banner,
|
|
1022
|
+
onCommand: async (line, tui) => {
|
|
1023
|
+
// The slash handlers were written for a scrolling terminal and print
|
|
1024
|
+
// with console.log. In the alternate buffer those writes would land
|
|
1025
|
+
// outside the frame and be erased by the next redraw, so they are
|
|
1026
|
+
// redirected into the transcript for the duration of the command.
|
|
1027
|
+
const realLog = console.log;
|
|
1028
|
+
const realError = console.error;
|
|
1029
|
+
const capture = (...parts) => {
|
|
1030
|
+
for (const chunk of parts.join(" ").split("\n"))
|
|
1031
|
+
tui.write(chunk);
|
|
1032
|
+
};
|
|
1033
|
+
console.log = capture;
|
|
1034
|
+
console.error = capture;
|
|
1035
|
+
try {
|
|
1036
|
+
const outcome = await handleSlashCommand(line, harness, tui.session, tui.agent);
|
|
1037
|
+
if (outcome === "exit")
|
|
1038
|
+
return "exit";
|
|
1039
|
+
if (outcome && typeof outcome === "object" && "session" in outcome)
|
|
1040
|
+
return outcome;
|
|
1041
|
+
if (outcome && typeof outcome === "object" && "login" in outcome) {
|
|
1042
|
+
tui.write("run `oharness auth login` in a normal terminal to sign in");
|
|
1043
|
+
}
|
|
1044
|
+
return undefined;
|
|
1045
|
+
}
|
|
1046
|
+
finally {
|
|
1047
|
+
console.log = realLog;
|
|
1048
|
+
console.error = realError;
|
|
1049
|
+
}
|
|
1050
|
+
},
|
|
1051
|
+
});
|
|
1052
|
+
// Approval and mode selection become overlays, so they draw inside the
|
|
1053
|
+
// frame instead of scribbling over it.
|
|
1054
|
+
installPrompt(app.permissionPrompt);
|
|
1055
|
+
installLogSink((line) => app.log(line));
|
|
1056
|
+
const running = app.run();
|
|
1057
|
+
if (args.prompt)
|
|
1058
|
+
await app.submitExternal(args.prompt);
|
|
1059
|
+
await running;
|
|
1060
|
+
await agent.end();
|
|
1061
|
+
}
|
|
1062
|
+
//# sourceMappingURL=main.js.map
|