@splinterzzz/ouro 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/LICENSE +21 -0
- package/dashboard-dist/assets/index-ETSLKv6w.css +1 -0
- package/dashboard-dist/assets/index-UHUWnWxM.js +63 -0
- package/dashboard-dist/favicon.svg +35 -0
- package/dashboard-dist/index.html +18 -0
- package/package.json +54 -0
- package/src/commands/dashboard.js +50 -0
- package/src/commands/init.js +75 -0
- package/src/commands/listen.js +164 -0
- package/src/commands/logs.js +91 -0
- package/src/commands/start.js +158 -0
- package/src/commands/status.js +69 -0
- package/src/commands/stop.js +39 -0
- package/src/index.js +81 -0
- package/src/lib/agentBackend.js +41 -0
- package/src/lib/agents.js +251 -0
- package/src/lib/claudeCodeExec.js +213 -0
- package/src/lib/codexExec.js +204 -0
- package/src/lib/config.js +91 -0
- package/src/lib/daemon.js +235 -0
- package/src/lib/env.js +135 -0
- package/src/lib/frontmatter.js +114 -0
- package/src/lib/github.js +60 -0
- package/src/lib/intake.js +151 -0
- package/src/lib/paths.js +57 -0
- package/src/lib/runs.js +55 -0
- package/src/lib/ship.js +114 -0
- package/src/lib/store.js +188 -0
- package/src/lib/telegram.js +60 -0
- package/src/lib/worktree.js +93 -0
- package/src/server/index.js +457 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* NOTE on the JSONL event shape: `codex exec --json` emits one JSON object
|
|
9
|
+
* per line to stdout. Field names below (`type`, `item.text`, `msg`, etc.)
|
|
10
|
+
* are based on public docs/community usage as of build time, not a locked
|
|
11
|
+
* spec — Codex's event schema has moved before. First thing to do when you
|
|
12
|
+
* sit down: run `codex exec --json "say hello"` once and diff the real
|
|
13
|
+
* output against the parsing in `parseLine()` below, then adjust. Don't
|
|
14
|
+
* build the rest of the pipeline on top of an unverified assumption here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const CODEX_BIN = process.env.OURO_CODEX_BIN || "codex";
|
|
18
|
+
|
|
19
|
+
const TRIAGE_SCHEMA = {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
summary: { type: "string" },
|
|
23
|
+
priority: { type: "string", enum: ["low", "medium", "high"] },
|
|
24
|
+
files_likely_affected: { type: "array", items: { type: "string" } },
|
|
25
|
+
},
|
|
26
|
+
required: ["summary", "priority", "files_likely_affected"],
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function parseLine(line) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(line);
|
|
33
|
+
} catch {
|
|
34
|
+
// Non-JSON stderr-ish noise sometimes rides along on stdout depending on
|
|
35
|
+
// version; surface it as a raw event instead of crashing the parser.
|
|
36
|
+
return { type: "raw", text: line };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Codex takes the agent's system prompt as a config override rather than a
|
|
42
|
+
* flag, and has no per-tool grant model — the sandbox mode governs writes.
|
|
43
|
+
* So an agent's tool list is advisory on this backend; it's the Claude Code
|
|
44
|
+
* path (--allowedTools) where it's enforced.
|
|
45
|
+
*/
|
|
46
|
+
function agentFlags(agent) {
|
|
47
|
+
if (!agent) return [];
|
|
48
|
+
const flags = [];
|
|
49
|
+
if (agent.model) flags.push("--model", agent.model);
|
|
50
|
+
if (agent.systemPrompt?.trim()) {
|
|
51
|
+
flags.push("-c", `experimental_instructions=${JSON.stringify(agent.systemPrompt.trim())}`);
|
|
52
|
+
}
|
|
53
|
+
return flags;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Runs `codex exec` and streams parsed JSONL events to `onEvent`.
|
|
58
|
+
* Resolves with { code, sessionId, lastMessage } when the process exits.
|
|
59
|
+
*/
|
|
60
|
+
function runCodexExec(args, { cwd, onEvent, signal } = {}) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
// `signal` makes cancellation kill the child process. See lib/runs.js.
|
|
63
|
+
const proc = spawn(CODEX_BIN, args, { cwd, env: process.env, signal });
|
|
64
|
+
|
|
65
|
+
const rl = readline.createInterface({ input: proc.stdout });
|
|
66
|
+
let sessionId = null;
|
|
67
|
+
let lastMessage = null;
|
|
68
|
+
|
|
69
|
+
rl.on("line", (line) => {
|
|
70
|
+
if (!line.trim()) return;
|
|
71
|
+
const event = parseLine(line);
|
|
72
|
+
if (event.session_id) sessionId = event.session_id;
|
|
73
|
+
if (event.type === "result" || event.subtype === "success") {
|
|
74
|
+
lastMessage = event.result ?? event.text ?? lastMessage;
|
|
75
|
+
}
|
|
76
|
+
onEvent?.(event);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let stderrBuf = "";
|
|
80
|
+
proc.stderr.on("data", (chunk) => {
|
|
81
|
+
stderrBuf += chunk.toString();
|
|
82
|
+
onEvent?.({ type: "stderr", text: chunk.toString() });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
proc.on("error", (err) => {
|
|
86
|
+
// Abort is an expected cancel, not a crash — resolve so the route can
|
|
87
|
+
// mark the ticket cancelled instead of erroring.
|
|
88
|
+
if (err.name === "AbortError" || signal?.aborted) {
|
|
89
|
+
resolve({ code: null, sessionId, lastMessage, stderr: stderrBuf, aborted: true });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
reject(err);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
proc.on("close", (code) => {
|
|
96
|
+
resolve({ code, sessionId, lastMessage, stderr: stderrBuf, aborted: Boolean(signal?.aborted) });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseJsonish(text) {
|
|
102
|
+
const cleaned = String(text ?? "").replace(/```json|```/g, "").trim();
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(cleaned);
|
|
105
|
+
} catch {
|
|
106
|
+
// Fall back to the outermost {...} in case prose leaked in around it.
|
|
107
|
+
const start = cleaned.indexOf("{");
|
|
108
|
+
const end = cleaned.lastIndexOf("}");
|
|
109
|
+
if (start !== -1 && end > start) {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(cleaned.slice(start, end + 1));
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Read-only JSON-in/JSON-out call — backs the Telegram intake interview. */
|
|
121
|
+
export async function askJson({ prompt, cwd, signal }) {
|
|
122
|
+
const { lastMessage } = await runCodexExec(["exec", prompt, "--json", "--sandbox", "read-only"], { cwd, signal });
|
|
123
|
+
return parseJsonish(lastMessage);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Triage: read-only, schema-constrained call. Cheap, safe, no repo writes.
|
|
128
|
+
*/
|
|
129
|
+
export async function triage({ prompt, cwd, signal }) {
|
|
130
|
+
// os.tmpdir(), not a hardcoded /tmp — this has to work on Windows too. The
|
|
131
|
+
// pid suffix keeps concurrent triages off one another's schema file.
|
|
132
|
+
const schemaPath = path.join(os.tmpdir(), `ouro-triage-schema-${process.pid}.json`);
|
|
133
|
+
fs.writeFileSync(schemaPath, JSON.stringify(TRIAGE_SCHEMA));
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const { lastMessage } = await runCodexExec(
|
|
137
|
+
["exec", prompt, "--json", "--sandbox", "read-only", "--output-schema", schemaPath],
|
|
138
|
+
{ cwd, signal }
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
parseJsonish(lastMessage) ?? {
|
|
143
|
+
summary: lastMessage ?? "(no summary returned)",
|
|
144
|
+
priority: "medium",
|
|
145
|
+
files_likely_affected: [],
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
} finally {
|
|
149
|
+
fs.rmSync(schemaPath, { force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Agent mode: full autonomy within the worktree, workspace-write sandbox,
|
|
155
|
+
* auto-approved. `onEvent` should be wired to the ticket's log + WS broadcast.
|
|
156
|
+
*/
|
|
157
|
+
export function runAgent({ prompt, cwd, onEvent, signal, agent }) {
|
|
158
|
+
return runCodexExec(
|
|
159
|
+
["exec", prompt, "--json", "--sandbox", "workspace-write", "--full-auto", ...agentFlags(agent)],
|
|
160
|
+
{ cwd, onEvent, signal }
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Human-in-the-loop, phase 1: plan only, read-only sandbox, no writes.
|
|
166
|
+
* Non-interactive headless mode can't reliably pause mid-run for approval
|
|
167
|
+
* (an unapproved action just fails the run rather than blocking on it), so
|
|
168
|
+
* instead we do two full calls: plan (read-only) -> show on card -> approve
|
|
169
|
+
* -> execute (write-enabled, resumed from the same session).
|
|
170
|
+
*/
|
|
171
|
+
export function planTicket({ prompt, cwd, onEvent, signal, agent }) {
|
|
172
|
+
return runCodexExec(
|
|
173
|
+
[
|
|
174
|
+
"exec",
|
|
175
|
+
`${prompt}\n\nDo not edit any files yet. First produce a short numbered plan of what you'd change and why.`,
|
|
176
|
+
"--json",
|
|
177
|
+
"--sandbox",
|
|
178
|
+
"read-only",
|
|
179
|
+
...agentFlags(agent),
|
|
180
|
+
],
|
|
181
|
+
{ cwd, onEvent, signal }
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Human-in-the-loop, phase 2: resume the planning session with a
|
|
187
|
+
* write-enabled sandbox, after the person clicks Approve.
|
|
188
|
+
*/
|
|
189
|
+
export function executeTicket({ cwd, sessionId, onEvent, signal, agent }) {
|
|
190
|
+
return runCodexExec(
|
|
191
|
+
[
|
|
192
|
+
"exec",
|
|
193
|
+
"resume",
|
|
194
|
+
sessionId ?? "--last",
|
|
195
|
+
"Approved. Implement the plan now and run relevant tests.",
|
|
196
|
+
"--json",
|
|
197
|
+
"--sandbox",
|
|
198
|
+
"workspace-write",
|
|
199
|
+
"--full-auto",
|
|
200
|
+
...agentFlags(agent),
|
|
201
|
+
],
|
|
202
|
+
{ cwd, onEvent, signal }
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { configPath } from "./paths.js";
|
|
3
|
+
import { maskToken } from "./telegram.js";
|
|
4
|
+
|
|
5
|
+
// `.ouro/config.json` is the one piece of state a human is expected to hand-edit,
|
|
6
|
+
// so reads are always fresh from disk and writes merge rather than replace —
|
|
7
|
+
// an unknown key someone added by hand survives a dashboard toggle.
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
version: 1,
|
|
11
|
+
backend: "claude-code",
|
|
12
|
+
defaultMode: "human", // safe default: plan first, write only after approval
|
|
13
|
+
// Commit + push + open a PR automatically once a run finishes with changes.
|
|
14
|
+
// On by default so a run ends somewhere a human can review it, rather than
|
|
15
|
+
// as an unpushed branch in a local worktree. Set false to require the
|
|
16
|
+
// explicit "Create PR" button instead — pushing is outward-facing, so this
|
|
17
|
+
// is deliberately a knob and not a hardcode.
|
|
18
|
+
autoShip: true,
|
|
19
|
+
telegram: {
|
|
20
|
+
botTokenEnvVar: "OURO_TELEGRAM_BOT_TOKEN",
|
|
21
|
+
chatIdEnvVar: "OURO_TELEGRAM_CHAT_ID",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function readConfig() {
|
|
26
|
+
try {
|
|
27
|
+
return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(configPath(), "utf-8")) };
|
|
28
|
+
} catch {
|
|
29
|
+
return { ...DEFAULTS };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeConfig(patch) {
|
|
34
|
+
const next = { ...readConfig(), ...patch };
|
|
35
|
+
fs.writeFileSync(configPath(), JSON.stringify(next, null, 2));
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_TELEGRAM_TOKEN_VAR = "OURO_TELEGRAM_BOT_TOKEN";
|
|
40
|
+
|
|
41
|
+
const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The *name* of the env var holding the bot token — never the token itself.
|
|
45
|
+
*
|
|
46
|
+
* Returns `{ name, error? }`. The error case is a real one people hit: the key
|
|
47
|
+
* reads like somewhere to put a token, so a token goes in it, and every caller
|
|
48
|
+
* then looks up process.env["8123:AA…"], finds undefined, and reports
|
|
49
|
+
* "8123:AA… is not set" — which blames the token for the config's mistake.
|
|
50
|
+
* Worse, config.json is committed on purpose (that's the point of
|
|
51
|
+
* agents-as-files), so a token parked here is a token headed for git history.
|
|
52
|
+
*/
|
|
53
|
+
export function telegramTokenVar() {
|
|
54
|
+
const value = readConfig().telegram?.botTokenEnvVar;
|
|
55
|
+
if (!value) return { name: DEFAULT_TELEGRAM_TOKEN_VAR };
|
|
56
|
+
if (ENV_VAR_NAME.test(value)) return { name: value };
|
|
57
|
+
|
|
58
|
+
// Newlines rather than one long string: this is printed to a terminal by two
|
|
59
|
+
// commands and rendered in the dashboard, and none of them own a wrapper.
|
|
60
|
+
return {
|
|
61
|
+
name: DEFAULT_TELEGRAM_TOKEN_VAR,
|
|
62
|
+
error: [
|
|
63
|
+
`.ouro/config.json has telegram.botTokenEnvVar set to "${maskToken(value)}".`,
|
|
64
|
+
`That field takes the NAME of the env var holding your token — "${DEFAULT_TELEGRAM_TOKEN_VAR}" — not the token itself.`,
|
|
65
|
+
`config.json is committed on purpose, so treat that token as exposed: revoke it via @BotFather, set the field back`,
|
|
66
|
+
`to "${DEFAULT_TELEGRAM_TOKEN_VAR}", and put the new token in .ouro/.env (gitignored) or the dashboard's Settings screen.`,
|
|
67
|
+
].join("\n"),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const MODES = ["human", "agent"];
|
|
72
|
+
|
|
73
|
+
export function getDefaultMode() {
|
|
74
|
+
const mode = readConfig().defaultMode;
|
|
75
|
+
return MODES.includes(mode) ? mode : "human";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function setDefaultMode(mode) {
|
|
79
|
+
if (!MODES.includes(mode)) throw new Error(`Unknown mode: ${mode}`);
|
|
80
|
+
writeConfig({ defaultMode: mode });
|
|
81
|
+
return mode;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getAutoShip() {
|
|
85
|
+
return readConfig().autoShip !== false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function setAutoShip(on) {
|
|
89
|
+
writeConfig({ autoShip: Boolean(on) });
|
|
90
|
+
return Boolean(on);
|
|
91
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { runDir, logsDir, repoRoot, ensureOuroDir } from "./paths.js";
|
|
6
|
+
|
|
7
|
+
// Background process supervision for `ouro start` / `ouro stop`.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately not pm2/forever/systemd: ouro is a single-operator tool that
|
|
10
|
+
// roots into one repo, and a pid file plus a detached spawn is the whole job.
|
|
11
|
+
// Adding a process manager would mean a global daemon with its own state,
|
|
12
|
+
// which is exactly the thing ouro avoids everywhere else.
|
|
13
|
+
//
|
|
14
|
+
// The two services:
|
|
15
|
+
// dashboard — Express + WS, holds ticket state, spawns agent CLIs
|
|
16
|
+
// listen — Telegram intake agent, talks to the dashboard over HTTP
|
|
17
|
+
// They're separate processes so restarting the bot can't drop board state,
|
|
18
|
+
// which is the same split the foreground commands already had.
|
|
19
|
+
|
|
20
|
+
export const SERVICES = ["dashboard", "listen"];
|
|
21
|
+
|
|
22
|
+
const CLI_ENTRY = fileURLToPath(new URL("../index.js", import.meta.url));
|
|
23
|
+
|
|
24
|
+
// A 24/7 process writes logs forever. Rotate one generation at start; two
|
|
25
|
+
// files bounded at 5MB is plenty to debug "why did the bot stop overnight",
|
|
26
|
+
// and beats either unbounded growth or a logrotate dependency.
|
|
27
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
function pidFile(name) {
|
|
30
|
+
return path.join(runDir(), `${name}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function logFile(name) {
|
|
34
|
+
return path.join(logsDir(), `${name}.log`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readRecord(name) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(pidFile(name), "utf-8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeRecord(name, record) {
|
|
46
|
+
ensureOuroDir();
|
|
47
|
+
fs.writeFileSync(pidFile(name), JSON.stringify(record, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function clearRecord(name) {
|
|
51
|
+
fs.rmSync(pidFile(name), { force: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Merges fields into an existing record — e.g. the port, once it's confirmed. */
|
|
55
|
+
export function updateRecord(name, patch) {
|
|
56
|
+
const current = readRecord(name);
|
|
57
|
+
if (!current) return null;
|
|
58
|
+
const next = { ...current, ...patch };
|
|
59
|
+
writeRecord(name, next);
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Signal 0 probes for existence without delivering anything. */
|
|
64
|
+
export function isAlive(pid) {
|
|
65
|
+
if (!pid) return false;
|
|
66
|
+
try {
|
|
67
|
+
process.kill(pid, 0);
|
|
68
|
+
return true;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// EPERM means it exists but is owned by someone else — still alive.
|
|
71
|
+
return err.code === "EPERM";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A pid file whose process is gone is stale — from a reboot, or a crash. Clean
|
|
77
|
+
* it up on read so `status` and `start` never trust a recycled pid.
|
|
78
|
+
*/
|
|
79
|
+
export function serviceStatus(name) {
|
|
80
|
+
const record = readRecord(name);
|
|
81
|
+
if (!record) return { name, running: false };
|
|
82
|
+
|
|
83
|
+
if (!isAlive(record.pid)) {
|
|
84
|
+
clearRecord(name);
|
|
85
|
+
return { name, running: false, stale: true };
|
|
86
|
+
}
|
|
87
|
+
return { name, running: true, ...record };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function statusAll() {
|
|
91
|
+
return SERVICES.map(serviceStatus);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function rotate(file) {
|
|
95
|
+
try {
|
|
96
|
+
if (fs.statSync(file).size > MAX_LOG_BYTES) fs.renameSync(file, `${file}.1`);
|
|
97
|
+
} catch {
|
|
98
|
+
// No log yet, or a Windows lock — not worth failing a start over.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Spawns a service detached from this terminal.
|
|
104
|
+
*
|
|
105
|
+
* `detached: true` puts the child in its own process group (POSIX) / process
|
|
106
|
+
* tree root (Windows), which is what lets it outlive the shell — and what
|
|
107
|
+
* lets stopService kill its agent grandchildren later. stdout/stderr go to a
|
|
108
|
+
* file rather than a pipe, because a pipe with no reader fills its buffer and
|
|
109
|
+
* wedges the child once the parent exits.
|
|
110
|
+
*/
|
|
111
|
+
export function startService(name, args = []) {
|
|
112
|
+
ensureOuroDir();
|
|
113
|
+
const file = logFile(name);
|
|
114
|
+
rotate(file);
|
|
115
|
+
|
|
116
|
+
// Where this run's output starts. Logs are append-only across restarts, so
|
|
117
|
+
// without this a failed start would tail yesterday's errors and send you
|
|
118
|
+
// debugging a problem you already fixed.
|
|
119
|
+
const logOffset = fs.existsSync(file) ? fs.statSync(file).size : 0;
|
|
120
|
+
|
|
121
|
+
const fd = fs.openSync(file, "a");
|
|
122
|
+
try {
|
|
123
|
+
const child = spawn(process.execPath, [CLI_ENTRY, name, ...args], {
|
|
124
|
+
cwd: repoRoot(),
|
|
125
|
+
detached: true,
|
|
126
|
+
windowsHide: true, // no flashing console window on Windows
|
|
127
|
+
stdio: ["ignore", fd, fd],
|
|
128
|
+
env: process.env,
|
|
129
|
+
});
|
|
130
|
+
child.unref(); // don't hold this CLI's event loop open
|
|
131
|
+
|
|
132
|
+
const record = { pid: child.pid, startedAt: new Date().toISOString(), args, logOffset };
|
|
133
|
+
writeRecord(name, record);
|
|
134
|
+
return record;
|
|
135
|
+
} finally {
|
|
136
|
+
fs.closeSync(fd); // the child holds its own dup of the fd
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function killTree(pid) {
|
|
141
|
+
if (process.platform === "win32") {
|
|
142
|
+
// /T kills the whole tree. Without it, a running `claude` child is
|
|
143
|
+
// orphaned and keeps burning subscription tokens with nothing watching it.
|
|
144
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Negative pid targets the process group — same reasoning as /T above.
|
|
148
|
+
try {
|
|
149
|
+
process.kill(-pid, "SIGTERM");
|
|
150
|
+
} catch {
|
|
151
|
+
try {
|
|
152
|
+
process.kill(pid, "SIGTERM");
|
|
153
|
+
} catch {
|
|
154
|
+
// already gone
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function forceKillTree(pid) {
|
|
160
|
+
if (process.platform === "win32") return; // taskkill /F was already forceful
|
|
161
|
+
try {
|
|
162
|
+
process.kill(-pid, "SIGKILL");
|
|
163
|
+
} catch {
|
|
164
|
+
try {
|
|
165
|
+
process.kill(pid, "SIGKILL");
|
|
166
|
+
} catch {
|
|
167
|
+
// already gone
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Stops a service and its children. Returns what actually happened so the
|
|
176
|
+
* command can report honestly rather than always printing "stopped".
|
|
177
|
+
*/
|
|
178
|
+
export async function stopService(name, { timeoutMs = 5000 } = {}) {
|
|
179
|
+
const record = readRecord(name);
|
|
180
|
+
if (!record) return { name, stopped: false, reason: "not running" };
|
|
181
|
+
|
|
182
|
+
if (!isAlive(record.pid)) {
|
|
183
|
+
clearRecord(name);
|
|
184
|
+
return { name, stopped: false, reason: "stale pid file (process already gone)" };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
killTree(record.pid);
|
|
188
|
+
|
|
189
|
+
// Give it a moment to go down cleanly; the dashboard flushes its store and
|
|
190
|
+
// cancels in-flight runs on SIGTERM (POSIX). Windows has no real signals, so
|
|
191
|
+
// taskkill /F is immediate — the store's debounced write and the
|
|
192
|
+
// in_progress→cancelled reconciliation on next boot cover that gap.
|
|
193
|
+
const deadline = Date.now() + timeoutMs;
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
if (!isAlive(record.pid)) {
|
|
196
|
+
clearRecord(name);
|
|
197
|
+
return { name, stopped: true, pid: record.pid };
|
|
198
|
+
}
|
|
199
|
+
await sleep(100);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
forceKillTree(record.pid);
|
|
203
|
+
await sleep(200);
|
|
204
|
+
|
|
205
|
+
const gone = !isAlive(record.pid);
|
|
206
|
+
if (gone) clearRecord(name);
|
|
207
|
+
return { name, stopped: gone, pid: record.pid, forced: true, ...(gone ? {} : { reason: "refused to die" }) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Last N lines of a service log. `fromOffset` limits it to output written
|
|
212
|
+
* after a given byte position — pass a start record's `logOffset` to show only
|
|
213
|
+
* the run that just failed, rather than the whole file's history.
|
|
214
|
+
*/
|
|
215
|
+
export function tailLog(name, lines = 20, fromOffset = 0) {
|
|
216
|
+
try {
|
|
217
|
+
let text = fs.readFileSync(logFile(name), "utf-8");
|
|
218
|
+
if (fromOffset > 0) text = Buffer.from(text, "utf-8").subarray(fromOffset).toString("utf-8");
|
|
219
|
+
return text.split(/\r?\n/).filter(Boolean).slice(-lines);
|
|
220
|
+
} catch {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function uptime(startedAt) {
|
|
226
|
+
const ms = Date.now() - new Date(startedAt).getTime();
|
|
227
|
+
if (!Number.isFinite(ms) || ms < 0) return "?";
|
|
228
|
+
const s = Math.floor(ms / 1000);
|
|
229
|
+
if (s < 60) return `${s}s`;
|
|
230
|
+
const m = Math.floor(s / 60);
|
|
231
|
+
if (m < 60) return `${m}m`;
|
|
232
|
+
const h = Math.floor(m / 60);
|
|
233
|
+
if (h < 24) return `${h}h ${m % 60}m`;
|
|
234
|
+
return `${Math.floor(h / 24)}d ${h % 24}h`;
|
|
235
|
+
}
|
package/src/lib/env.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { envPath, ensureOuroDir } from "./paths.js";
|
|
3
|
+
|
|
4
|
+
// `.ouro/.env` — secrets for the background services.
|
|
5
|
+
//
|
|
6
|
+
// `ouro start` detaches the daemon from the shell that launched it, so the
|
|
7
|
+
// daemon can't inherit a token you only exported in that shell: close the
|
|
8
|
+
// terminal, or reboot, and `ouro listen` comes back up with no token and dies.
|
|
9
|
+
// A file the daemon reads at startup is what makes 24/7 actually 24/7.
|
|
10
|
+
//
|
|
11
|
+
// Gitignored by `ouro init` (see .ouro/.gitignore) — it holds a bot token.
|
|
12
|
+
|
|
13
|
+
// Group 1 is the `export ` prefix, kept so a rewrite can put it back — this is
|
|
14
|
+
// somebody's hand-written file, not a generated one.
|
|
15
|
+
const LINE = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/;
|
|
16
|
+
|
|
17
|
+
function unquote(value) {
|
|
18
|
+
const v = value.trim();
|
|
19
|
+
if (v.length >= 2 && ((v[0] === '"' && v.at(-1) === '"') || (v[0] === "'" && v.at(-1) === "'"))) {
|
|
20
|
+
return v.slice(1, -1);
|
|
21
|
+
}
|
|
22
|
+
// An unquoted trailing `# comment` isn't part of the value.
|
|
23
|
+
return v.split(" #")[0].trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseEnv(text) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
29
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
30
|
+
const match = line.match(LINE);
|
|
31
|
+
if (match) out[match[2]] = unquote(match[3]);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Loads `.ouro/.env` into process.env. A real shell export always wins — if
|
|
38
|
+
* you deliberately set a token for one invocation, a stale file shouldn't
|
|
39
|
+
* silently override it.
|
|
40
|
+
*/
|
|
41
|
+
export function loadEnvFile() {
|
|
42
|
+
let text;
|
|
43
|
+
try {
|
|
44
|
+
text = fs.readFileSync(envPath(), "utf-8");
|
|
45
|
+
} catch {
|
|
46
|
+
return {}; // no file is the normal case, not an error
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const vars = parseEnv(text);
|
|
50
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
51
|
+
if (process.env[key] === undefined) process.env[key] = value;
|
|
52
|
+
}
|
|
53
|
+
return vars;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function hasEnvFile() {
|
|
57
|
+
return fs.existsSync(envPath());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What's actually in the file, without touching process.env. */
|
|
61
|
+
export function readEnvVars() {
|
|
62
|
+
try {
|
|
63
|
+
return parseEnv(fs.readFileSync(envPath(), "utf-8"));
|
|
64
|
+
} catch {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const HEADER = [
|
|
70
|
+
"# ouro secrets — read by the background services at startup.",
|
|
71
|
+
"# Gitignored (.ouro/.gitignore). Written by the dashboard's Settings screen,",
|
|
72
|
+
"# and safe to hand-edit: comments and unknown keys survive a save.",
|
|
73
|
+
"",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Bare words don't need quoting; anything with whitespace, a `#`, or a quote
|
|
77
|
+
// would come back wrong through unquote(), so it goes through JSON.
|
|
78
|
+
function serialize(value) {
|
|
79
|
+
const v = String(value);
|
|
80
|
+
return /^[A-Za-z0-9_\-.:/@+=]*$/.test(v) ? v : JSON.stringify(v);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Merges vars into `.ouro/.env`. A `null` value deletes the key.
|
|
85
|
+
*
|
|
86
|
+
* Rewrites lines in place rather than regenerating the file, because this file
|
|
87
|
+
* has two authors: the dashboard and a human with an editor. Comments, order,
|
|
88
|
+
* `export ` prefixes and keys ouro knows nothing about all outlive a save.
|
|
89
|
+
*/
|
|
90
|
+
export function writeEnvVars(patch) {
|
|
91
|
+
ensureOuroDir();
|
|
92
|
+
|
|
93
|
+
let existing = null;
|
|
94
|
+
try {
|
|
95
|
+
existing = fs.readFileSync(envPath(), "utf-8");
|
|
96
|
+
} catch {
|
|
97
|
+
// No file yet — the header is what tells whoever opens it later what it is.
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const pending = new Map(Object.entries(patch));
|
|
101
|
+
const next = [];
|
|
102
|
+
|
|
103
|
+
for (const line of existing === null ? HEADER : existing.split(/\r?\n/)) {
|
|
104
|
+
const match = line.trim().startsWith("#") ? null : line.match(LINE);
|
|
105
|
+
const key = match?.[2];
|
|
106
|
+
if (!key || !pending.has(key)) {
|
|
107
|
+
next.push(line);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const value = pending.get(key);
|
|
111
|
+
pending.delete(key);
|
|
112
|
+
if (value !== null) next.push(`${match[1]}${key}=${serialize(value)}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const [key, value] of pending) {
|
|
116
|
+
if (value !== null) next.push(`${key}=${serialize(value)}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
while (next.length && !next.at(-1).trim()) next.pop(); // no growing tail of blanks
|
|
120
|
+
|
|
121
|
+
// 0600 applies on create only, which is the case that matters: a token
|
|
122
|
+
// shouldn't land world-readable in the first place.
|
|
123
|
+
fs.writeFileSync(envPath(), next.join("\n") + "\n", { mode: 0o600 });
|
|
124
|
+
|
|
125
|
+
// The file and this process's env have to move together: everything here
|
|
126
|
+
// reads tokens from process.env, and a service spawned after this call
|
|
127
|
+
// inherits it (see lib/daemon.js). Unlike loadEnvFile, an explicit save wins
|
|
128
|
+
// over a shell export — you just told us what the token is.
|
|
129
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
130
|
+
if (value === null) delete process.env[key];
|
|
131
|
+
else process.env[key] = String(value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return readEnvVars();
|
|
135
|
+
}
|