@opennous/cli 0.15.0 → 0.17.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/CHANGELOG.md +18 -0
- package/README.md +14 -3
- package/package.json +2 -2
- package/src/backfill.js +54 -2
- package/src/certHint.js +1 -1
- package/src/hooks/icp-sync-reminder.mjs +5 -5
- package/src/index.js +159 -27
- package/src/runner.js +205 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @opennous/cli changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Added: `runner`, reading on your own plan all the time
|
|
6
|
+
|
|
7
|
+
`backfill` reads your history once. `runner` keeps going: it reads your new calls and email on your
|
|
8
|
+
own Claude or ChatGPT plan as they come in, with the same no-tools sandbox. `runner install` starts it
|
|
9
|
+
now and every time you log in (launchd on macOS, systemd on Linux); `runner status` and
|
|
10
|
+
`runner uninstall` check on it and remove it. When your plan hits its limit it waits and carries on
|
|
11
|
+
by itself. The app's Settings → Your plan shows it as connected (GET-1289).
|
|
12
|
+
|
|
13
|
+
## 0.16.0
|
|
14
|
+
|
|
15
|
+
### Added: your Intelligence report after `backfill`
|
|
16
|
+
|
|
17
|
+
When the read is done, `backfill` writes your Intelligence report: private to you, built from what it
|
|
18
|
+
read. Its executive summary, recommended next steps and objection responses are written by your own model, from the report's data only,
|
|
19
|
+
with no tools. The command prints the link. If that step fails, the report is still there (GET-1199).
|
|
20
|
+
|
|
3
21
|
## 0.15.0
|
|
4
22
|
|
|
5
23
|
### Added: Slack in `backfill`
|
package/README.md
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
# @opennous/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ownable CLI — set Ownable up, connect it to your coding agent, and read the Context API from your terminal.
|
|
4
4
|
|
|
5
5
|
## Get started
|
|
6
6
|
|
|
7
|
-
One command. It signs you in (creating your account if you don't have one), registers the
|
|
7
|
+
One command. It signs you in (creating your account if you don't have one), registers the Ownable MCP server with the agent on your machine, and hands off. The same command on macOS, Linux, and Windows (needs [Node 18+](https://nodejs.org)):
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npx @opennous/cli@latest init
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Then tell your agent *"set up my
|
|
13
|
+
Then tell your agent *"set up my Ownable workspace"* — it reads your project, finds your ICP (or drafts one), and syncs it. On self-host, add `--url https://api.yourdomain.com`.
|
|
14
|
+
|
|
15
|
+
## Read on your own plan
|
|
16
|
+
|
|
17
|
+
Reading your calls and email runs on your own Claude Code or Codex plan, not on credits:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx @opennous/cli@latest backfill # your history, once
|
|
21
|
+
npx @opennous/cli@latest runner install # what's new, from now on; starts at login
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`runner status` says whether it's running, `runner uninstall` stops it. Settings → Your plan in the app shows each runner and what's waiting to be read.
|
|
14
25
|
|
|
15
26
|
## Event tracking
|
|
16
27
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennous/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.17.0",
|
|
4
|
+
"description": "Ownable CLI — set up Ownable, connect it to your coding agent, and read the Context API from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"nous": "src/index.js"
|
package/src/backfill.js
CHANGED
|
@@ -105,12 +105,41 @@ export function estimateLine(days, counts) {
|
|
|
105
105
|
return `Last ${days} days: about ${counts?.email_thread ?? 0} email threads, ${counts?.meeting ?? 0} meetings, ${counts?.call ?? 0} calls${slack}.`;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* "5 to review: https://…/review (12 not on file)", or null when there is nothing to decide. Pure.
|
|
110
|
+
* To review is Probably mentions plus open groups (GET-1152); a mention nothing on file could be is
|
|
111
|
+
* not on file and never counted. An older server sends only `mentions`.
|
|
112
|
+
*/
|
|
113
|
+
export function reviewLine(review) {
|
|
114
|
+
const n = Number(review?.to_review ?? review?.mentions ?? 0);
|
|
115
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
116
|
+
const quiet = Number(review?.not_on_file ?? 0);
|
|
117
|
+
return `${n} to review${review?.url ? `: ${review.url}` : ""}${quiet > 0 ? ` (${quiet} not on file)` : ""}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Who each person is, before anything is read (GET-1159): the people worth a look, as a short table,
|
|
122
|
+
* with where to confirm them. Null when there is nothing to show. Pure.
|
|
123
|
+
*/
|
|
124
|
+
export function proposalsLines(rel) {
|
|
125
|
+
if (!rel || !(rel.judged > 0)) return null;
|
|
126
|
+
const pct = (c) => `${Math.round(Number(c ?? 0) * 100)}%`;
|
|
127
|
+
const lines = [`Who's who: ${rel.judged} people judged, ${rel.to_confirm ?? 0} worth a look.`];
|
|
128
|
+
const top = (rel.top ?? []).slice(0, 15);
|
|
129
|
+
const w = Math.min(28, Math.max(4, ...top.map((p) => String(p.name ?? "Unknown").length)));
|
|
130
|
+
for (const p of top) lines.push(` ${String(p.name ?? "Unknown").slice(0, w).padEnd(w)} ${String(p.group ?? "").padEnd(14)} ${pct(p.confidence).padStart(4)} ${p.reason ?? ""}`.trimEnd());
|
|
131
|
+
if (rel.url) lines.push(`Confirm or fix them: ${rel.url}`);
|
|
132
|
+
lines.push(rel.waiting ? "Waiting for you to confirm before reading (or Ctrl-C and run with --yes)." : rel.mode === "yes" ? "Accepted as proposed." : "Reading now; fixing someone later re-reads their calls.");
|
|
133
|
+
return lines;
|
|
134
|
+
}
|
|
135
|
+
|
|
108
136
|
/** Nothing to read in this window. Pure. */
|
|
109
137
|
export const emptyEstimate = (counts) => !Object.values(counts ?? {}).some((n) => n > 0);
|
|
110
138
|
|
|
111
139
|
/** One line on how far the job is, or null when there is nothing to say yet. Pure. */
|
|
112
140
|
export function progressLine(reading, answered, rerun = RERUN) {
|
|
113
141
|
if (reading?.finished) return "Wrapping up: last summaries for your accounts.";
|
|
142
|
+
if (reading?.kind === "relationships") return `Working out who each person is, before reading. ${`Stop any time with Ctrl-C; run \`${rerun}\` again to carry on.`}`;
|
|
114
143
|
const stop = `Stop any time with Ctrl-C; run \`${rerun}\` again to carry on.`;
|
|
115
144
|
const what = { meetings: "meetings", slack: "Slack conversations" }[reading?.kind] ?? "email threads";
|
|
116
145
|
if (reading?.total) return `Read ${reading.done} of ${reading.total} ${what}. ${stop}`;
|
|
@@ -124,7 +153,7 @@ export function progressLine(reading, answered, rerun = RERUN) {
|
|
|
124
153
|
* is saved, and unanswered calls come back to the next run.
|
|
125
154
|
*/
|
|
126
155
|
export async function runBackfill({ api, agent, jobId, run, sleep, log, now = Date.now, workers = WORKERS, limitWaitMs = LIMIT_WAIT_MS, pollMs = POLL_MS, progressMs = PROGRESS_MS, rerun = RERUN }) {
|
|
127
|
-
let waitUntil = 0, answered = 0, lastLine = null, lastAt = -Infinity;
|
|
156
|
+
let waitUntil = 0, answered = 0, lastLine = null, lastAt = -Infinity, shownProposals = false;
|
|
128
157
|
const say = (line) => {
|
|
129
158
|
if (!line || (line === lastLine && now() - lastAt < progressMs) || now() - lastAt < Math.min(progressMs, 1000)) return;
|
|
130
159
|
lastLine = line; lastAt = now(); log(line);
|
|
@@ -149,10 +178,15 @@ export async function runBackfill({ api, agent, jobId, run, sleep, log, now = Da
|
|
|
149
178
|
};
|
|
150
179
|
for (;;) {
|
|
151
180
|
const page = await api.calls(jobId, workers);
|
|
152
|
-
if (page.status === "done") return { status: "done", answered, read: page.reading?.read ?? 0, failed: page.reading?.failed ?? 0 };
|
|
181
|
+
if (page.status === "done") return { status: "done", answered, read: page.reading?.read ?? 0, failed: page.reading?.failed ?? 0, ...(page.memory_review ? { memoryReview: page.memory_review } : {}) };
|
|
153
182
|
if (page.status === "failed") throw new Error(page.error || "The backfill failed on our side.");
|
|
154
183
|
// The server gave up waiting for us (a long sleep, say). We're back, so pick it up.
|
|
155
184
|
if (page.status === "paused_own_model") { await api.resume?.(); await sleep(pollMs); continue; }
|
|
185
|
+
// Phase 1's proposals (GET-1159), once, as soon as they are in.
|
|
186
|
+
if (!shownProposals && page.relationships?.judged > 0) {
|
|
187
|
+
shownProposals = true;
|
|
188
|
+
for (const l of proposalsLines(page.relationships) ?? []) log(l);
|
|
189
|
+
}
|
|
156
190
|
say(progressLine(page.reading, answered, rerun));
|
|
157
191
|
if (!page.calls.length) { await sleep(pollMs); continue; }
|
|
158
192
|
await Promise.all(page.calls.map((c) => answerOne(c, page.models)));
|
|
@@ -164,3 +198,21 @@ export function liveRun() {
|
|
|
164
198
|
const cwd = mkdtempSync(join(tmpdir(), "nous-backfill-"));
|
|
165
199
|
return (command) => runCommand(command, cwd);
|
|
166
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The Intelligence report (GET-1199), once the read is done. The server builds it from what was
|
|
204
|
+
* filed; its Summary and Recommended actions are then written by your own model, from the report's
|
|
205
|
+
* data only, with no tools. If that fails the report keeps the ones code wrote. Returns its link,
|
|
206
|
+
* or null. Injected like runBackfill: `api` ({ report, writing }), `run`.
|
|
207
|
+
*/
|
|
208
|
+
export async function writeReport({ api, agent, jobId, run }) {
|
|
209
|
+
const r = await api.report(jobId);
|
|
210
|
+
const models = r?.models?.[agent];
|
|
211
|
+
if (r?.writing && models) {
|
|
212
|
+
try {
|
|
213
|
+
const { text } = parseAnswer(agent, await run(commandFor(agent, models, r.writing)));
|
|
214
|
+
await api.writing(jobId, { answer: text, model: models.model });
|
|
215
|
+
} catch { /* the report stands as code wrote it */ }
|
|
216
|
+
}
|
|
217
|
+
return r?.url ?? null;
|
|
218
|
+
}
|
package/src/certHint.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// A self-hosted
|
|
1
|
+
// A self-hosted Ownable on a laptop serves https://api.localhost with Caddy's own local CA, which
|
|
2
2
|
// Node does not trust. fetch then rejects with a bare "fetch failed" whose cause carries one of
|
|
3
3
|
// these codes. Name the fix in one line instead of printing a stack trace (GET-1011).
|
|
4
4
|
const UNTRUSTED_CERT_CODES = new Set([
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// Ownable ICP auto-sync reminder (installed by `npx @opennous/cli init`).
|
|
3
3
|
//
|
|
4
|
-
// A Claude Code PostToolUse hook. After the agent edits
|
|
4
|
+
// A Claude Code PostToolUse hook. After the agent edits an Ownable context file, it
|
|
5
5
|
// reminds the agent that the file is INERT until synced: the app, the ICP tab, and
|
|
6
|
-
// the ICP scoring model read the
|
|
6
|
+
// the ICP scoring model read the Ownable GRAPH, not the repo file, so an edit does
|
|
7
7
|
// nothing until mcp__nous__sync_icp runs. Standalone Node (no bash/jq) so it behaves
|
|
8
8
|
// identically on Windows, macOS, and Linux.
|
|
9
9
|
|
|
@@ -25,8 +25,8 @@ process.stdin.on("end", () => {
|
|
|
25
25
|
const base = norm.split("/").pop().toLowerCase();
|
|
26
26
|
|
|
27
27
|
const ctx = base === "icp.md"
|
|
28
|
-
? `You just edited ${rel}, your
|
|
29
|
-
: `You edited
|
|
28
|
+
? `You just edited ${rel}, your Ownable ICP file. It is INERT until synced: the app, the ICP tab, and the ICP scoring model read the Ownable graph, not this file. Call the mcp__nous__sync_icp tool THIS TURN with section "ICP" and source_path "${rel}", passing the file body as the section content, so the graph and the scoring model rebuild from your edit. Do not end the turn with the ICP file edited but unsynced.`
|
|
29
|
+
: `You edited an Ownable context file (${rel}). It stays inert until synced into the Ownable graph. If it is an ICP, positioning, pricing, competitors, or market file, call mcp__nous__sync_icp this turn with the matching section and its source_path so the graph updates.`;
|
|
30
30
|
|
|
31
31
|
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: ctx } }));
|
|
32
32
|
} catch { /* never block a tool call */ }
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// Ownable CLI — the setup + auth utility. NOT a data surface.
|
|
4
4
|
//
|
|
5
5
|
// It signs you in (browser device flow → key in ~/.nous/config.json) and installs the
|
|
6
6
|
// opennous plugin into your agent (`init`). All data work happens in the agent through
|
|
@@ -120,7 +120,8 @@ function apiClient(key) {
|
|
|
120
120
|
process.exit(1);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
// soft: a failure throws instead of ending the process, for a step that may fail on its own.
|
|
124
|
+
async function request(method, path, { body, query, soft = false } = {}) {
|
|
124
125
|
const url = new URL(path, apiUrl);
|
|
125
126
|
if (query) {
|
|
126
127
|
for (const [k, v] of Object.entries(query)) {
|
|
@@ -135,11 +136,13 @@ function apiClient(key) {
|
|
|
135
136
|
body: body ? JSON.stringify(body) : undefined,
|
|
136
137
|
});
|
|
137
138
|
} catch (e) {
|
|
139
|
+
if (soft) throw e;
|
|
138
140
|
console.error(certHint(e) || `Couldn't reach ${apiUrl}: ${e.message}`);
|
|
139
141
|
process.exit(1);
|
|
140
142
|
}
|
|
141
143
|
if (!res.ok) {
|
|
142
144
|
const err = await res.json().catch(() => ({ message: res.statusText }));
|
|
145
|
+
if (soft) throw new Error(err.message || err.error || `HTTP ${res.status}`);
|
|
143
146
|
console.error(`Error: ${err.message || err.error}`);
|
|
144
147
|
process.exit(1);
|
|
145
148
|
}
|
|
@@ -150,6 +153,7 @@ function apiClient(key) {
|
|
|
150
153
|
return {
|
|
151
154
|
get: (path, query) => request("GET", path, { query }),
|
|
152
155
|
post: (path, body) => request("POST", path, { body }),
|
|
156
|
+
softPost: (path, body) => request("POST", path, { body, soft: true }),
|
|
153
157
|
};
|
|
154
158
|
}
|
|
155
159
|
|
|
@@ -163,7 +167,7 @@ program
|
|
|
163
167
|
.addCommand(
|
|
164
168
|
new Command("login")
|
|
165
169
|
.description("Save your API key")
|
|
166
|
-
.requiredOption("--key <key>", "Your
|
|
170
|
+
.requiredOption("--key <key>", "Your Ownable API key")
|
|
167
171
|
.option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
|
|
168
172
|
.action(({ key, url }) => {
|
|
169
173
|
const cfg = readConfig();
|
|
@@ -205,7 +209,7 @@ program
|
|
|
205
209
|
// ---------------------------------------------------------------------------
|
|
206
210
|
// You're already signed in (the saved key identifies you), so there's no device-code
|
|
207
211
|
// dance — we start a Composio consent, open it in your browser, and poll until the
|
|
208
|
-
// account goes ACTIVE. Composio holds the OAuth token;
|
|
212
|
+
// account goes ACTIVE. Composio holds the OAuth token; Ownable never sees it.
|
|
209
213
|
const CONNECT_ALIASES = {
|
|
210
214
|
gmail: "gmail_oauth",
|
|
211
215
|
gmail_oauth: "gmail_oauth",
|
|
@@ -329,11 +333,11 @@ program
|
|
|
329
333
|
});
|
|
330
334
|
|
|
331
335
|
// ---------------------------------------------------------------------------
|
|
332
|
-
// Connect
|
|
336
|
+
// Connect Ownable to whatever agent is on this machine — host-aware.
|
|
333
337
|
//
|
|
334
338
|
// The MCP server (7 primitives) is the universal capability: any MCP host runs `npx @opennous/mcp`.
|
|
335
339
|
// But the SKILLS + routing are the product surface, and those are delivered per host:
|
|
336
|
-
// • Claude Code → the
|
|
340
|
+
// • Claude Code → the Ownable PLUGIN (skills + routing hooks + the trimmed MCP, one install).
|
|
337
341
|
// • Any other host (Cursor, Codex, Claude Desktop, …) → the trimmed MCP via a `.mcp.json`;
|
|
338
342
|
// each host's skills pack ships as we light it up.
|
|
339
343
|
// So `init` installs the plugin on Claude Code, and writes the MCP config everywhere else — one
|
|
@@ -388,7 +392,7 @@ function writeJsonMcp(path, apiUrl) {
|
|
|
388
392
|
const PLUGIN_MARKETPLACE = "NousC/revenue-plugin";
|
|
389
393
|
const PLUGIN_ID = "opennous@opennous";
|
|
390
394
|
|
|
391
|
-
// Claude Code → install the
|
|
395
|
+
// Claude Code → install the Ownable PLUGIN (skills + routing hooks + the trimmed MCP, in one).
|
|
392
396
|
// We try the plugin CLI; if this Claude build has no `plugin` subcommand it returns null and the
|
|
393
397
|
// caller prints the two slash-commands instead. We deliberately do NOT fall back to a bare
|
|
394
398
|
// `claude mcp add` — a standalone MCP server would duplicate the plugin's and drop the skills.
|
|
@@ -404,11 +408,11 @@ function installClaudePlugin() {
|
|
|
404
408
|
function registerMcpServer(apiUrl) {
|
|
405
409
|
const path = join(process.cwd(), ".mcp.json");
|
|
406
410
|
writeJsonMcp(path, apiUrl);
|
|
407
|
-
return `Wrote ${path} — your agent will pick up
|
|
411
|
+
return `Wrote ${path} — your agent will pick up Ownable (7 primitives) from it.`;
|
|
408
412
|
}
|
|
409
413
|
|
|
410
414
|
// Install the ICP auto-sync hook into the project's Claude Code config. Once in place,
|
|
411
|
-
// editing
|
|
415
|
+
// editing an Ownable context file (context/**/icp.md, etc.) reminds the agent to call
|
|
412
416
|
// sync_icp that turn — because an edited ICP file is INERT until synced into the graph.
|
|
413
417
|
// Copies our packaged hook script into .claude/hooks/ and merges the PostToolUse entry
|
|
414
418
|
// into .claude/settings.json WITHOUT clobbering any existing hooks or settings.
|
|
@@ -447,7 +451,7 @@ function installIcpSyncHook() {
|
|
|
447
451
|
// This is the command the install script and the marketing site point to.
|
|
448
452
|
program
|
|
449
453
|
.command("init")
|
|
450
|
-
.description("Get started — sign in and connect
|
|
454
|
+
.description("Get started — sign in and connect Ownable to your agent")
|
|
451
455
|
.option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
|
|
452
456
|
.option("--no-register", "Skip registering the MCP server (just sign in)")
|
|
453
457
|
.action(async ({ url, register }) => {
|
|
@@ -460,41 +464,41 @@ program
|
|
|
460
464
|
let installed = false;
|
|
461
465
|
try { installed = installClaudePlugin(); } catch { /* fall through to instructions */ }
|
|
462
466
|
if (installed) {
|
|
463
|
-
console.log("\n✓ Installed the
|
|
464
|
-
console.log(" Open Claude Code and
|
|
467
|
+
console.log("\n✓ Installed the Ownable plugin into Claude Code — skills, routing, and the 8 primitives.");
|
|
468
|
+
console.log(" Open Claude Code and Ownable starts onboarding automatically.");
|
|
465
469
|
console.log(dim(" (Already have it open? Start a new session so the skills load.)"));
|
|
466
470
|
} else {
|
|
467
|
-
console.log("\nDetected Claude Code. Install the
|
|
471
|
+
console.log("\nDetected Claude Code. Install the Ownable plugin (skills + hooks + tools):");
|
|
468
472
|
console.log(" /plugin marketplace add NousC/revenue-plugin");
|
|
469
473
|
console.log(" /plugin install opennous@opennous");
|
|
470
|
-
console.log(dim(" Then open Claude Code —
|
|
474
|
+
console.log(dim(" Then open Claude Code — Ownable starts onboarding on its own."));
|
|
471
475
|
}
|
|
472
476
|
} else {
|
|
473
477
|
try {
|
|
474
478
|
console.log(`\n${registerMcpServer(apiUrl)}`);
|
|
475
479
|
} catch {
|
|
476
|
-
console.log("\nCouldn't write .mcp.json automatically. Add
|
|
480
|
+
console.log("\nCouldn't write .mcp.json automatically. Add Ownable to your host's MCP config:");
|
|
477
481
|
console.log(` command: ${MCP_CMD} args: ${MCP_ARGS.join(" ")} env: NOUS_SURFACE=plugin`);
|
|
478
482
|
}
|
|
479
483
|
}
|
|
480
484
|
|
|
481
485
|
// Auto-sync the ICP: a Claude Code hook so editing your ICP file reminds the
|
|
482
|
-
// agent to sync it into
|
|
486
|
+
// agent to sync it into Ownable (the file is inert until synced). Best-effort —
|
|
483
487
|
// a convenience, never a reason to fail setup.
|
|
484
488
|
try {
|
|
485
489
|
const already = installIcpSyncHook();
|
|
486
|
-
if (!already) console.log("Installed the ICP auto-sync hook — edits to your ICP file now sync to
|
|
490
|
+
if (!already) console.log("Installed the ICP auto-sync hook — edits to your ICP file now sync to Ownable automatically.");
|
|
487
491
|
} catch { /* non-fatal */ }
|
|
488
492
|
}
|
|
489
493
|
|
|
490
|
-
console.log("\n✅ Signed in. Open Claude Code —
|
|
494
|
+
console.log("\n✅ Signed in. Open Claude Code — Ownable will walk you through onboarding on its own.");
|
|
491
495
|
console.log("\n" + dim("Docs: https://docs.opennous.cloud/mcp/introduction"));
|
|
492
496
|
});
|
|
493
497
|
|
|
494
498
|
// nous pull — write the Vault into ./context/*.md and hand authorship to the repo.
|
|
495
499
|
//
|
|
496
500
|
// The bridge for the Google road. A user who set up in the browser has a Vault whose author
|
|
497
|
-
// is
|
|
501
|
+
// is Ownable (source 'nous', no file). The day they open a repo and connect an agent, that
|
|
498
502
|
// arrangement is backwards: they now live in files, and the Vault should mirror the files,
|
|
499
503
|
// not the other way round. `pull` flips it — it writes each context doc to disk and re-syncs
|
|
500
504
|
// it WITH its path, which sets source='claude_code'. From then on their repo is the author,
|
|
@@ -506,7 +510,7 @@ const PULL_KINDS = { icp: "icp.md" };
|
|
|
506
510
|
|
|
507
511
|
program
|
|
508
512
|
.command("pull")
|
|
509
|
-
.description("Write your
|
|
513
|
+
.description("Write your Ownable context docs into ./context and let your repo own them")
|
|
510
514
|
.option("--dir <path>", "Where to write the context/ folder (default: cwd)", process.cwd())
|
|
511
515
|
.option("--force", "Overwrite files that already exist", false)
|
|
512
516
|
.action(async ({ dir, force }) => {
|
|
@@ -542,7 +546,7 @@ program
|
|
|
542
546
|
|
|
543
547
|
console.log(`\n✓ ${written} written${skipped ? `, ${skipped} skipped` : ""}. Your repo now owns your context.`);
|
|
544
548
|
if (synced < written) console.log(dim(" (some didn't sync back — re-run `nous pull` to finish)"));
|
|
545
|
-
console.log(dim("Edit these files and your agent syncs them with sync_foundation.
|
|
549
|
+
console.log(dim("Edit these files and your agent syncs them with sync_foundation. Ownable mirrors them."));
|
|
546
550
|
});
|
|
547
551
|
|
|
548
552
|
// nous logout — top-level alias of `auth logout`. Removes the saved API key.
|
|
@@ -552,10 +556,11 @@ program
|
|
|
552
556
|
.option("--agent <agent>", "claude or codex (default: whichever you're signed in to)")
|
|
553
557
|
.option("--days <days>", "how many days back to read, 1 to 730", "180")
|
|
554
558
|
.option("--workspace <id>", "the workspace to read; the app's command fills this in")
|
|
555
|
-
.option("--yes", "start without asking")
|
|
559
|
+
.option("--yes", "start without asking, and accept who each person is as proposed")
|
|
560
|
+
.option("--confirm", "wait until you confirm who each person is in Routing before reading")
|
|
556
561
|
.option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
|
|
557
562
|
.action(async (opts) => {
|
|
558
|
-
const { detectAgents, runBackfill, liveRun, emptyEstimate, estimateLine, rerunCommand, NO_AGENT } = await import("./backfill.js");
|
|
563
|
+
const { detectAgents, runBackfill, liveRun, emptyEstimate, estimateLine, rerunCommand, reviewLine, writeReport, NO_AGENT } = await import("./backfill.js");
|
|
559
564
|
const days = Number(opts.days);
|
|
560
565
|
if (!Number.isInteger(days) || days < 1 || days > 730) {
|
|
561
566
|
console.error("--days must be a whole number from 1 to 730.");
|
|
@@ -602,7 +607,7 @@ program
|
|
|
602
607
|
}
|
|
603
608
|
console.log(`\nWorkspace: ${workspace}`);
|
|
604
609
|
console.log(estimateLine(days, est.counts));
|
|
605
|
-
console.log(`They're filtered first, then read on your ${agent} plan. None of it uses your
|
|
610
|
+
console.log(`They're filtered first, then read on your ${agent} plan. None of it uses your Ownable credits.`);
|
|
606
611
|
console.log("You can stop at any time and run this again: it carries on where it stopped.\n");
|
|
607
612
|
if (!opts.yes) {
|
|
608
613
|
const { createInterface } = await import("node:readline/promises");
|
|
@@ -611,25 +616,152 @@ program
|
|
|
611
616
|
rl.close();
|
|
612
617
|
if (!ok) return;
|
|
613
618
|
}
|
|
614
|
-
|
|
619
|
+
// Who each person is (GET-1159): read with the proposals by default; --confirm waits for you, --yes accepts them.
|
|
620
|
+
const confirm = opts.confirm ? "wait" : opts.yes ? "yes" : "proceed";
|
|
621
|
+
const job = await api.post("/v2/backfill/own", { days, confirm });
|
|
615
622
|
console.log(job.resumed ? "Picking up where you left off. Stop any time with Ctrl-C." : `Started. Stop any time with Ctrl-C; run \`${rerun}\` again to carry on.`);
|
|
616
623
|
process.once("SIGINT", () => {
|
|
617
624
|
console.log(`\nStopped. Everything read so far is saved. Run \`${rerun}\` to carry on.`);
|
|
618
625
|
process.exit(130);
|
|
619
626
|
});
|
|
627
|
+
const run = liveRun();
|
|
620
628
|
const result = await runBackfill({
|
|
621
|
-
agent, jobId: job.job_id, run
|
|
629
|
+
agent, jobId: job.job_id, run, log: console.log, rerun,
|
|
622
630
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
623
631
|
api: {
|
|
624
632
|
calls: (id, limit) => api.get(`/v2/backfill/own/${id}/calls`, { limit }),
|
|
625
633
|
answer: (id, body) => api.post(`/v2/backfill/own/calls/${id}`, body),
|
|
626
|
-
resume: () => api.post("/v2/backfill/own", { days }),
|
|
634
|
+
resume: () => api.post("/v2/backfill/own", { days, confirm }),
|
|
627
635
|
},
|
|
628
636
|
});
|
|
629
637
|
console.log(`Done. This run: ${result.read} conversations read, ${result.answered} model calls on your ${agent} plan.`);
|
|
630
638
|
if (result.failed) console.log(`${result.failed} conversations couldn't be read. Run \`${rerun}\` again to retry them.`);
|
|
639
|
+
const review = reviewLine(result.memoryReview);
|
|
640
|
+
if (review) console.log(review);
|
|
641
|
+
// The Intelligence report (GET-1199): private to you, its summary written on your plan.
|
|
642
|
+
console.log("Writing your Intelligence report…");
|
|
643
|
+
const reportUrl = await writeReport({
|
|
644
|
+
agent, jobId: job.job_id, run,
|
|
645
|
+
api: {
|
|
646
|
+
report: (id) => api.softPost(`/v2/backfill/own/${id}/report`, {}),
|
|
647
|
+
writing: (id, body) => api.softPost(`/v2/backfill/own/${id}/report/writing`, body),
|
|
648
|
+
},
|
|
649
|
+
}).catch(() => null);
|
|
650
|
+
if (reportUrl) console.log(`Your Intelligence report is ready: ${reportUrl}`);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// `nous runner` (GET-1293): keeps reading your new calls and email on your own plan. `install`
|
|
654
|
+
// starts it at login; the app's "Your plan" page shows it as connected once it has polled.
|
|
655
|
+
const runnerCmd = program
|
|
656
|
+
.command("runner")
|
|
657
|
+
.description("Keep reading your new calls and email on your own Claude or ChatGPT plan")
|
|
658
|
+
.option("--agent <agent>", "claude or codex (default: whichever you're signed in to)")
|
|
659
|
+
.option("--workspace <id>", "the workspace to read; the app's command fills this in")
|
|
660
|
+
.option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
|
|
661
|
+
.action(async (opts) => {
|
|
662
|
+
const { detectAgents, liveRun, NO_AGENT } = await import("./backfill.js");
|
|
663
|
+
const { runRunner } = await import("./runner.js");
|
|
664
|
+
const agent = await pickAgent(opts.agent, detectAgents, NO_AGENT);
|
|
665
|
+
const key = await runnerKey(opts);
|
|
666
|
+
const api = apiClient(key);
|
|
667
|
+
const service = process.env.NOUS_RUNNER_SERVICE === "1";
|
|
668
|
+
const stamp = (line) => (service ? `${new Date().toISOString()} ${line}` : line);
|
|
669
|
+
console.log(stamp(`Reading on your ${agent} plan. ${service ? "" : "Stop with Ctrl-C; `nous runner install` keeps it going on its own."}`.trim()));
|
|
670
|
+
await runRunner({
|
|
671
|
+
agent, version, run: liveRun(), log: (l) => console.log(stamp(l)),
|
|
672
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
673
|
+
api: {
|
|
674
|
+
poll: (body) => api.softPost("/v2/runner/poll", body),
|
|
675
|
+
answer: (id, body) => api.softPost(`/v2/runner/calls/${id}`, body),
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
runnerCmd
|
|
681
|
+
.command("install")
|
|
682
|
+
.description("Start the runner now and every time you log in")
|
|
683
|
+
.option("--agent <agent>", "claude or codex (default: whichever you're signed in to)")
|
|
684
|
+
.option("--workspace <id>", "the workspace to read; the app's command fills this in")
|
|
685
|
+
.option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
|
|
686
|
+
.action(async (opts) => {
|
|
687
|
+
const { detectAgents, NO_AGENT } = await import("./backfill.js");
|
|
688
|
+
const { installService } = await import("./runner.js");
|
|
689
|
+
const agent = await pickAgent(opts.agent, detectAgents, NO_AGENT);
|
|
690
|
+
const key = await runnerKey(opts);
|
|
691
|
+
// One poll before installing, so a bad key or plan fails here and not silently in the background.
|
|
692
|
+
try {
|
|
693
|
+
await apiClient(key).softPost("/v2/runner/poll", { agent, host: hostname(), version, limit: 0 });
|
|
694
|
+
} catch (e) {
|
|
695
|
+
console.error(`Couldn't connect: ${e.message}`);
|
|
696
|
+
process.exit(1);
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
const where = installService({ agent, workspace: opts.workspace ?? null });
|
|
700
|
+
console.log(`Connected. The runner reads on your ${agent} plan and starts every time you log in.`);
|
|
701
|
+
if (where.log) console.log(`Log: ${where.log}`);
|
|
702
|
+
console.log("Check on it with `npx @opennous/cli@latest runner status`. Stop it with `npx @opennous/cli@latest runner uninstall`.");
|
|
703
|
+
} catch (e) {
|
|
704
|
+
console.error(e.message);
|
|
705
|
+
process.exit(1);
|
|
706
|
+
}
|
|
631
707
|
});
|
|
632
708
|
|
|
709
|
+
runnerCmd
|
|
710
|
+
.command("uninstall")
|
|
711
|
+
.description("Stop the runner and stop it starting at login")
|
|
712
|
+
.action(async () => {
|
|
713
|
+
const { uninstallService } = await import("./runner.js");
|
|
714
|
+
console.log(uninstallService() ? "Stopped. It won't start at login any more." : "No runner is installed on this machine.");
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
runnerCmd
|
|
718
|
+
.command("status")
|
|
719
|
+
.description("Whether the runner is installed and running on this machine")
|
|
720
|
+
.action(async () => {
|
|
721
|
+
const { serviceStatus } = await import("./runner.js");
|
|
722
|
+
const s = serviceStatus();
|
|
723
|
+
if (!s.installed) { console.log("Not installed. Run `npx @opennous/cli@latest runner install`."); return; }
|
|
724
|
+
console.log(s.running ? "Running." : "Installed, but not running right now.");
|
|
725
|
+
if (s.log) console.log(`Log: ${s.log}`);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
/** The agent to read with: the one asked for, or whichever is signed in. Exits with what to do. */
|
|
729
|
+
async function pickAgent(asked, detectAgents, noAgent) {
|
|
730
|
+
const agents = detectAgents();
|
|
731
|
+
const agent = asked ?? agents[0];
|
|
732
|
+
if (!agent || !agents.includes(agent)) {
|
|
733
|
+
console.error(asked ? `You're not signed in to ${asked}. Run \`${asked === "codex" ? "codex login" : "claude"}\` first.` : noAgent);
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
return agent;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** The key the runner reads with: for --workspace when given (signing in to it if needed), else the saved one. */
|
|
740
|
+
async function runnerKey(opts) {
|
|
741
|
+
if (opts.url) writeConfig({ ...readConfig(), apiUrl: opts.url });
|
|
742
|
+
if (!hasKey()) await browserLogin(opts.url);
|
|
743
|
+
const cfg = readConfig();
|
|
744
|
+
const apiUrl = opts.url || process.env.NOUS_API_URL || cfg.apiUrl || "https://api.opennous.cloud";
|
|
745
|
+
let key = process.env.NOUS_API_KEY || cfg.apiKey;
|
|
746
|
+
if (!opts.workspace) return key;
|
|
747
|
+
const kept = cfg.workspaceKeys?.[opts.workspace];
|
|
748
|
+
for (const k of [key, kept].filter(Boolean)) {
|
|
749
|
+
if ((await fetchWhoami(apiUrl, k))?.workspace?.id === opts.workspace) {
|
|
750
|
+
if (k !== kept) writeConfig({ ...cfg, workspaceKeys: { ...cfg.workspaceKeys, [opts.workspace]: k } });
|
|
751
|
+
return k;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
console.log("Sign in to the workspace this runner is for.");
|
|
755
|
+
key = await browserLogin(opts.url, { save: false });
|
|
756
|
+
if ((await fetchWhoami(apiUrl, key))?.workspace?.id !== opts.workspace) {
|
|
757
|
+
console.error("That sign-in is for another workspace. Open the right workspace in the app, then run this again.");
|
|
758
|
+
process.exit(1);
|
|
759
|
+
}
|
|
760
|
+
const saved = readConfig();
|
|
761
|
+
writeConfig({ ...saved, workspaceKeys: { ...saved.workspaceKeys, [opts.workspace]: key } });
|
|
762
|
+
return key;
|
|
763
|
+
}
|
|
764
|
+
|
|
633
765
|
program
|
|
634
766
|
.command("logout")
|
|
635
767
|
.description("Sign out — remove the saved API key")
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// `nous runner` (GET-1293): reads your new calls and email on your own Claude or ChatGPT plan, all
|
|
2
|
+
// the time. It is `nous backfill`'s loop that never ends: it polls the server, answers each model
|
|
3
|
+
// call with the `claude` or `codex` CLI you are signed in to (no tools, the same sandbox as backfill:
|
|
4
|
+
// a call's prompt holds other people's words), and posts the answer back. The server decides what to
|
|
5
|
+
// read; this only answers. `nous runner install` starts it at login, so it keeps going on its own.
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir, hostname, platform } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { commandFor, parseAnswer, isPlanLimit } from "./backfill.js";
|
|
12
|
+
|
|
13
|
+
export const WORKERS = 3;
|
|
14
|
+
/** Busy: there is a read going, so ask again soon. */
|
|
15
|
+
export const BUSY_MS = 2000;
|
|
16
|
+
/** Idle: nothing going. A heartbeat still has to land well inside the server's two minutes. */
|
|
17
|
+
export const IDLE_MS = 20_000;
|
|
18
|
+
/** At the plan's limit: take nothing, keep the heartbeat. */
|
|
19
|
+
export const LIMITED_POLL_MS = 60_000;
|
|
20
|
+
export const LIMIT_WAIT_MS = 30 * 60_000;
|
|
21
|
+
const RETRIES = 2;
|
|
22
|
+
const ERROR_BACKOFF_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
export const LABEL = "cloud.opennous.runner";
|
|
25
|
+
export const RUN_COMMAND = "npx -y @opennous/cli@latest runner";
|
|
26
|
+
|
|
27
|
+
/** What the status line says about the read in progress, or null. Pure. */
|
|
28
|
+
export function readingLine(reading) {
|
|
29
|
+
if (!reading) return null;
|
|
30
|
+
const what = reading.live ? "Reading what's new" : "Reading your history";
|
|
31
|
+
if (reading.status === "paused_own_model") return `${what}: picking it back up.`;
|
|
32
|
+
if (reading.finished) return `${what}: wrapping up.`;
|
|
33
|
+
if (reading.total) return `${what}: ${reading.done ?? 0} of ${reading.total}.`;
|
|
34
|
+
return `${what}.`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The loop. Injected: `api` ({ poll, answer }), `run` (one command → stdout), `sleep`, `log`.
|
|
39
|
+
* `stop()` → true ends it (tests; the real one runs until the process is stopped).
|
|
40
|
+
*/
|
|
41
|
+
export async function runRunner({ api, agent, host = hostname(), version = null, run, sleep, log, now = Date.now,
|
|
42
|
+
workers = WORKERS, limitWaitMs = LIMIT_WAIT_MS, stop = () => false }) {
|
|
43
|
+
let limitedUntil = 0, answered = 0, lastLine = null, lastError = null, toldBackfill = false;
|
|
44
|
+
const answerOne = async (call, models) => {
|
|
45
|
+
for (let attempt = 0; ; attempt++) {
|
|
46
|
+
try {
|
|
47
|
+
const { text, usage } = parseAnswer(agent, await run(commandFor(agent, models[agent], call)));
|
|
48
|
+
await api.answer(call.id, { answer: text, model: models[agent].model, usage });
|
|
49
|
+
answered++;
|
|
50
|
+
return;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// The plan's limit: drop the call (its lease runs out and it comes back) and stop taking
|
|
53
|
+
// more until the limit lifts. The heartbeat keeps going, so the app shows why.
|
|
54
|
+
if (isPlanLimit(err.message)) {
|
|
55
|
+
if (now() >= limitedUntil) log(`Your ${agent} plan hit its limit. Reading waits ${Math.round(limitWaitMs / 60000)} minutes, then carries on by itself.`);
|
|
56
|
+
limitedUntil = Math.max(limitedUntil, now() + limitWaitMs);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (attempt >= RETRIES) { await api.answer(call.id, { error: err.message }).catch(() => {}); return; }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
while (!stop()) {
|
|
64
|
+
let page;
|
|
65
|
+
try {
|
|
66
|
+
page = await api.poll({ agent, host, version, answered, limit: workers,
|
|
67
|
+
limited_until: limitedUntil > now() ? new Date(limitedUntil).toISOString() : null });
|
|
68
|
+
if (lastError) { log("Back online."); lastError = null; }
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err.message !== lastError) log(`Waiting on Ownable: ${err.message}. Trying again every ${ERROR_BACKOFF_MS / 1000} seconds.`);
|
|
71
|
+
lastError = err.message;
|
|
72
|
+
await sleep(ERROR_BACKOFF_MS);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (page.needs_backfill && !toldBackfill) {
|
|
76
|
+
toldBackfill = true;
|
|
77
|
+
log("Your history hasn't been read yet. Run `npx @opennous/cli@latest backfill` once; after that this keeps it current.");
|
|
78
|
+
}
|
|
79
|
+
const line = readingLine(page.reading);
|
|
80
|
+
if (line && line !== lastLine) { log(line); }
|
|
81
|
+
lastLine = line;
|
|
82
|
+
const calls = page.calls ?? [];
|
|
83
|
+
if (!calls.length) { await sleep(limitedUntil > now() ? LIMITED_POLL_MS : page.reading ? BUSY_MS : IDLE_MS); continue; }
|
|
84
|
+
await Promise.all(calls.map((c) => answerOne(c, page.models)));
|
|
85
|
+
}
|
|
86
|
+
return { answered };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Start at login ───────────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/** The absolute path of a command, or null. */
|
|
92
|
+
export function which(cmd, run = spawnSync) {
|
|
93
|
+
const r = run("/bin/sh", ["-c", `command -v ${cmd}`], { encoding: "utf8" });
|
|
94
|
+
const p = r.status === 0 ? String(r.stdout).trim().split("\n")[0] : "";
|
|
95
|
+
return p.startsWith("/") ? p : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** PATH for the service: where node, npx and the agent CLIs live, then the usual places. Pure. */
|
|
99
|
+
export function servicePath(binaries) {
|
|
100
|
+
const dirs = [...new Set(binaries.filter(Boolean).map((b) => dirname(b)))];
|
|
101
|
+
for (const d of ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]) if (!dirs.includes(d)) dirs.push(d);
|
|
102
|
+
return dirs.join(":");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
106
|
+
|
|
107
|
+
/** The launchd agent for macOS. Pure. */
|
|
108
|
+
export function launchdPlist({ npx, agent, workspace = null, path, log }) {
|
|
109
|
+
const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : [])];
|
|
110
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
111
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
112
|
+
<plist version="1.0">
|
|
113
|
+
<dict>
|
|
114
|
+
<key>Label</key><string>${LABEL}</string>
|
|
115
|
+
<key>ProgramArguments</key>
|
|
116
|
+
<array>
|
|
117
|
+
${args.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
|
|
118
|
+
</array>
|
|
119
|
+
<key>EnvironmentVariables</key>
|
|
120
|
+
<dict>
|
|
121
|
+
<key>PATH</key><string>${xml(path)}</string>
|
|
122
|
+
<key>NOUS_RUNNER_SERVICE</key><string>1</string>
|
|
123
|
+
</dict>
|
|
124
|
+
<key>RunAtLoad</key><true/>
|
|
125
|
+
<key>KeepAlive</key><true/>
|
|
126
|
+
<key>ThrottleInterval</key><integer>30</integer>
|
|
127
|
+
<key>StandardOutPath</key><string>${xml(log)}</string>
|
|
128
|
+
<key>StandardErrorPath</key><string>${xml(log)}</string>
|
|
129
|
+
</dict>
|
|
130
|
+
</plist>
|
|
131
|
+
`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The systemd user unit for Linux. Pure. */
|
|
135
|
+
export function systemdUnit({ npx, agent, workspace = null, path }) {
|
|
136
|
+
const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : [])];
|
|
137
|
+
return `[Unit]
|
|
138
|
+
Description=Ownable runner: reads on your own ${agent === "codex" ? "ChatGPT" : "Claude"} plan
|
|
139
|
+
After=network-online.target
|
|
140
|
+
|
|
141
|
+
[Service]
|
|
142
|
+
ExecStart=${args.join(" ")}
|
|
143
|
+
Environment=PATH=${path}
|
|
144
|
+
Environment=NOUS_RUNNER_SERVICE=1
|
|
145
|
+
Restart=always
|
|
146
|
+
RestartSec=30
|
|
147
|
+
|
|
148
|
+
[Install]
|
|
149
|
+
WantedBy=default.target
|
|
150
|
+
`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Where the service files live on this machine. Pure. */
|
|
154
|
+
export function servicePaths(os = platform(), home = homedir()) {
|
|
155
|
+
if (os === "darwin") return { kind: "launchd", file: join(home, "Library", "LaunchAgents", `${LABEL}.plist`), log: join(home, "Library", "Logs", "opennous-runner.log") };
|
|
156
|
+
if (os === "linux") return { kind: "systemd", file: join(home, ".config", "systemd", "user", "opennous-runner.service"), log: null };
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Install and start the runner as a service. Returns { kind, file, log } or throws with what to do. */
|
|
161
|
+
export function installService({ agent, workspace = null, run = spawnSync, os = platform(), home = homedir() } = {}) {
|
|
162
|
+
const where = servicePaths(os, home);
|
|
163
|
+
if (!where) throw new Error(`Starting at login isn't supported on ${os} yet. Run \`${RUN_COMMAND}\` in a terminal you keep open instead.`);
|
|
164
|
+
const npx = which("npx", run);
|
|
165
|
+
if (!npx) throw new Error("Couldn't find npx. Install Node.js 20 or later, then run this again.");
|
|
166
|
+
const path = servicePath([npx, which("node", run), which(agent, run)]);
|
|
167
|
+
mkdirSync(dirname(where.file), { recursive: true });
|
|
168
|
+
if (where.kind === "launchd") {
|
|
169
|
+
mkdirSync(dirname(where.log), { recursive: true });
|
|
170
|
+
writeFileSync(where.file, launchdPlist({ npx, agent, workspace, path, log: where.log }));
|
|
171
|
+
run("launchctl", ["unload", where.file], { stdio: "ignore" });
|
|
172
|
+
const r = run("launchctl", ["load", "-w", where.file], { encoding: "utf8" });
|
|
173
|
+
if (r.status !== 0) throw new Error(`launchctl couldn't start it: ${(r.stderr || "").trim()}`);
|
|
174
|
+
} else {
|
|
175
|
+
writeFileSync(where.file, systemdUnit({ npx, agent, workspace, path }));
|
|
176
|
+
run("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
177
|
+
const r = run("systemctl", ["--user", "enable", "--now", "opennous-runner.service"], { encoding: "utf8" });
|
|
178
|
+
if (r.status !== 0) throw new Error(`systemd couldn't start it: ${(r.stderr || "").trim()}`);
|
|
179
|
+
// Keeps it running after logout, where the machine allows it. Best effort.
|
|
180
|
+
run("loginctl", ["enable-linger"], { stdio: "ignore" });
|
|
181
|
+
}
|
|
182
|
+
return where;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Stop and remove the service. True when there was one. */
|
|
186
|
+
export function uninstallService({ run = spawnSync, os = platform(), home = homedir() } = {}) {
|
|
187
|
+
const where = servicePaths(os, home);
|
|
188
|
+
if (!where || !existsSync(where.file)) return false;
|
|
189
|
+
if (where.kind === "launchd") run("launchctl", ["unload", "-w", where.file], { stdio: "ignore" });
|
|
190
|
+
else {
|
|
191
|
+
run("systemctl", ["--user", "disable", "--now", "opennous-runner.service"], { stdio: "ignore" });
|
|
192
|
+
}
|
|
193
|
+
rmSync(where.file, { force: true });
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Whether the service is installed, and running as far as the OS says. */
|
|
198
|
+
export function serviceStatus({ run = spawnSync, os = platform(), home = homedir() } = {}) {
|
|
199
|
+
const where = servicePaths(os, home);
|
|
200
|
+
if (!where || !existsSync(where.file)) return { installed: false, running: false, ...(where ?? {}) };
|
|
201
|
+
const r = where.kind === "launchd"
|
|
202
|
+
? run("launchctl", ["list", LABEL], { encoding: "utf8" })
|
|
203
|
+
: run("systemctl", ["--user", "is-active", "opennous-runner.service"], { encoding: "utf8" });
|
|
204
|
+
return { installed: true, running: r.status === 0, ...where };
|
|
205
|
+
}
|