@9thprotocol/cli 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 +16 -0
- package/README.md +43 -0
- package/dist/init.d.ts +2 -0
- package/dist/init.js +61 -0
- package/dist/login.d.ts +4 -0
- package/dist/login.js +161 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +53 -0
- package/dist/map.d.ts +2 -0
- package/dist/map.js +57 -0
- package/dist/repl.d.ts +1 -0
- package/dist/repl.js +266 -0
- package/dist/scripts/tui-smoke.d.ts +1 -0
- package/dist/scripts/tui-smoke.js +64 -0
- package/dist/serve.d.ts +8 -0
- package/dist/serve.js +225 -0
- package/dist/shared.d.ts +60 -0
- package/dist/shared.js +192 -0
- package/dist/tui/app.d.ts +28 -0
- package/dist/tui/app.js +191 -0
- package/dist/tui/index.d.ts +2 -0
- package/dist/tui/index.js +35 -0
- package/dist/webui.d.ts +13 -0
- package/dist/webui.js +1056 -0
- package/package.json +41 -0
package/dist/shared.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AUTO_MODEL, } from "@9thprotocol/agent-core";
|
|
5
|
+
export const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
6
|
+
export const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
7
|
+
export const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
8
|
+
export const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
9
|
+
/** Auto routing is the product default (PLAN.md §5.5); `/model <id>` pins a model. */
|
|
10
|
+
export const DEFAULT_MODEL = AUTO_MODEL;
|
|
11
|
+
const BIASES = ["economy", "balanced", "quality"];
|
|
12
|
+
/** Auto bias from NINEP_AUTO_BIAS or ~/.9p/config.json; defaults to balanced. */
|
|
13
|
+
export function getAutoBias() {
|
|
14
|
+
const fromEnv = process.env.NINEP_AUTO_BIAS;
|
|
15
|
+
if (fromEnv && BIASES.includes(fromEnv))
|
|
16
|
+
return fromEnv;
|
|
17
|
+
try {
|
|
18
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".9p", "config.json"), "utf8"));
|
|
19
|
+
if (cfg.autoBias && BIASES.includes(cfg.autoBias))
|
|
20
|
+
return cfg.autoBias;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// no config file
|
|
24
|
+
}
|
|
25
|
+
return "balanced";
|
|
26
|
+
}
|
|
27
|
+
export function setAutoBias(bias) {
|
|
28
|
+
const dir = path.join(os.homedir(), ".9p");
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
const file = path.join(dir, "config.json");
|
|
31
|
+
let cfg = {};
|
|
32
|
+
try {
|
|
33
|
+
cfg = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// fresh config
|
|
37
|
+
}
|
|
38
|
+
cfg.autoBias = bias;
|
|
39
|
+
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
|
|
40
|
+
}
|
|
41
|
+
export const AUTO_BIASES = BIASES;
|
|
42
|
+
/**
|
|
43
|
+
* Catalog with plan locks for the Auto router. Platform-only: BYOK has no plan
|
|
44
|
+
* gating, so the router falls back to its built-in ladders.
|
|
45
|
+
*/
|
|
46
|
+
export async function fetchCatalog(auth) {
|
|
47
|
+
if (!auth.platform)
|
|
48
|
+
return undefined;
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`${auth.platform.baseUrl}/models`, {
|
|
51
|
+
headers: { Authorization: `Bearer ${auth.apiKey}` },
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
return undefined;
|
|
55
|
+
const data = (await res.json());
|
|
56
|
+
// contextLength feeds compaction, without it every model falls back to a
|
|
57
|
+
// generic default and a 32k model would blow past its window.
|
|
58
|
+
return data.models?.map((m) => ({
|
|
59
|
+
id: m.id,
|
|
60
|
+
tier: m.tier,
|
|
61
|
+
locked: m.locked,
|
|
62
|
+
contextLength: m.contextLength ?? null,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Routing must never block a session on a catalog fetch, ladders still work.
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Auth resolution order:
|
|
72
|
+
* 1. NINEP_API_URL + NINEP_TOKEN (platform, metered)
|
|
73
|
+
* 2. ~/.9p/auth.json {"apiUrl", "token"} (platform)
|
|
74
|
+
* 3. OPENROUTER_API_KEY or ~/.9p/auth.json {"openrouterApiKey"} (BYOK, direct)
|
|
75
|
+
*/
|
|
76
|
+
export function getAuth() {
|
|
77
|
+
if (process.env.NINEP_API_URL && process.env.NINEP_TOKEN) {
|
|
78
|
+
return { apiKey: process.env.NINEP_TOKEN, platform: { baseUrl: process.env.NINEP_API_URL } };
|
|
79
|
+
}
|
|
80
|
+
let file = {};
|
|
81
|
+
try {
|
|
82
|
+
file = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".9p", "auth.json"), "utf8"));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// no auth file
|
|
86
|
+
}
|
|
87
|
+
if (file.apiUrl && file.token)
|
|
88
|
+
return { apiKey: file.token, platform: { baseUrl: file.apiUrl } };
|
|
89
|
+
if (process.env.OPENROUTER_API_KEY)
|
|
90
|
+
return { apiKey: process.env.OPENROUTER_API_KEY };
|
|
91
|
+
if (file.openrouterApiKey)
|
|
92
|
+
return { apiKey: file.openrouterApiKey };
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const authFilePath = () => path.join(os.homedir(), ".9p", "auth.json");
|
|
96
|
+
/** Seconds of remaining life below which we refresh rather than risk a mid-turn 401. */
|
|
97
|
+
const REFRESH_SKEW_S = 120;
|
|
98
|
+
/** `exp` from a JWT payload without verifying. We only need the expiry hint. */
|
|
99
|
+
function tokenExpiry(token) {
|
|
100
|
+
try {
|
|
101
|
+
const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
102
|
+
return typeof payload.exp === "number" ? payload.exp : null;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Exchange the stored refresh token for a fresh pair and persist it.
|
|
110
|
+
*
|
|
111
|
+
* Access tokens live 15 minutes, so without this a `9p login` session would 401
|
|
112
|
+
* partway through the first real task. Refresh tokens rotate server-side, so the
|
|
113
|
+
* new one must be written back or the next refresh fails.
|
|
114
|
+
*/
|
|
115
|
+
async function refreshStoredAuth() {
|
|
116
|
+
let file = {};
|
|
117
|
+
try {
|
|
118
|
+
file = JSON.parse(fs.readFileSync(authFilePath(), "utf8"));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const { apiUrl, refreshToken } = file;
|
|
124
|
+
if (typeof apiUrl !== "string" || typeof refreshToken !== "string")
|
|
125
|
+
return null;
|
|
126
|
+
try {
|
|
127
|
+
const res = await fetch(`${apiUrl}/auth/refresh`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "Content-Type": "application/json" },
|
|
130
|
+
body: JSON.stringify({ refreshToken }),
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok)
|
|
133
|
+
return null;
|
|
134
|
+
const next = (await res.json());
|
|
135
|
+
fs.writeFileSync(authFilePath(), JSON.stringify({ ...file, token: next.accessToken, refreshToken: next.refreshToken }, null, 2) + "\n", { mode: 0o600 });
|
|
136
|
+
return { apiKey: next.accessToken, platform: { baseUrl: apiUrl } };
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Resolve credentials, refreshing an expiring platform token first.
|
|
144
|
+
*
|
|
145
|
+
* Returns a reason rather than exiting, so hosts that are not a terminal (the
|
|
146
|
+
* desktop app) can surface it in their own UI. A library that calls
|
|
147
|
+
* `process.exit` kills an Electron app with a message nobody ever sees.
|
|
148
|
+
*/
|
|
149
|
+
export async function resolveAuth() {
|
|
150
|
+
const auth = getAuth();
|
|
151
|
+
if (auth?.platform && !process.env.NINEP_TOKEN) {
|
|
152
|
+
const exp = tokenExpiry(auth.apiKey);
|
|
153
|
+
if (exp !== null && exp - Date.now() / 1000 < REFRESH_SKEW_S) {
|
|
154
|
+
const refreshed = await refreshStoredAuth();
|
|
155
|
+
if (refreshed)
|
|
156
|
+
return { ok: true, auth: refreshed };
|
|
157
|
+
return { ok: false, reason: "expired" };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!auth)
|
|
161
|
+
return { ok: false, reason: "none" };
|
|
162
|
+
return { ok: true, auth };
|
|
163
|
+
}
|
|
164
|
+
/** CLI entry point: resolve credentials, or print and exit. */
|
|
165
|
+
export async function requireAuth() {
|
|
166
|
+
const result = await resolveAuth();
|
|
167
|
+
if (result.ok)
|
|
168
|
+
return result.auth;
|
|
169
|
+
console.error(result.reason === "expired"
|
|
170
|
+
? red("Session expired.") + ` Run ${cyan("9p login")} to sign in again.`
|
|
171
|
+
: red("No credentials.") +
|
|
172
|
+
` Run ${cyan("9p login")}, or set OPENROUTER_API_KEY for BYOK.`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
176
|
+
export function usageLine(u) {
|
|
177
|
+
return `${u.requests} calls · ${k(u.inputTokens)} in (${k(u.cachedTokens)} cached) · ${k(u.outputTokens)} out`;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* What delegation kept out of the context. Empty when nothing was delegated,
|
|
181
|
+
* so callers can append it unconditionally without printing a zero every turn.
|
|
182
|
+
*
|
|
183
|
+
* Phrased as context rather than money on purpose: those tokens are not merely
|
|
184
|
+
* unspent once, they are tokens that never get resent on any later turn, which
|
|
185
|
+
* is the whole reason a session window lasts longer with delegation on.
|
|
186
|
+
*/
|
|
187
|
+
export function delegationLine(d) {
|
|
188
|
+
if (!d.calls)
|
|
189
|
+
return "";
|
|
190
|
+
return `⇢ ${d.calls} delegated · ${k(d.contextTokensSaved)} tokens kept out of context`;
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=shared.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { AgentEvent, DelegationTotals, PermissionMode, Skill, UsageTotals } from "@9thprotocol/agent-core";
|
|
3
|
+
/** What the TUI needs from a session, narrow so tests can inject a fake. */
|
|
4
|
+
export interface AgentLike {
|
|
5
|
+
model: string;
|
|
6
|
+
mode: PermissionMode;
|
|
7
|
+
readonly usage: UsageTotals;
|
|
8
|
+
readonly delegated: DelegationTotals;
|
|
9
|
+
send(text: string): AsyncGenerator<AgentEvent>;
|
|
10
|
+
clear(): void;
|
|
11
|
+
}
|
|
12
|
+
export interface AppMeta {
|
|
13
|
+
cwd: string;
|
|
14
|
+
authLabel: string;
|
|
15
|
+
vault: string | null;
|
|
16
|
+
skills: Skill[];
|
|
17
|
+
mcpSummary: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface AppBridge {
|
|
20
|
+
requestPermission: (summary: string) => Promise<boolean>;
|
|
21
|
+
requestAnswer: (question: string, options: string[]) => Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
export declare function App({ session, meta, register, }: {
|
|
24
|
+
session: AgentLike;
|
|
25
|
+
meta: AppMeta;
|
|
26
|
+
/** Called on mount with the UI bridge for permission/ask prompts. */
|
|
27
|
+
register?: (bridge: AppBridge) => void;
|
|
28
|
+
}): React.JSX.Element;
|
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Static, Text, useApp, useInput } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
import { skillMessage } from "@9thprotocol/agent-core";
|
|
6
|
+
const kfmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
7
|
+
const MODES = ["default", "accept-edits", "plan", "bypass"];
|
|
8
|
+
export function App({ session, meta, register, }) {
|
|
9
|
+
const { exit } = useApp();
|
|
10
|
+
const [items, setItems] = useState(() => {
|
|
11
|
+
const banner = [
|
|
12
|
+
{ id: -4, kind: "banner", text: "9th Protocol (9p)" },
|
|
13
|
+
{
|
|
14
|
+
id: -3,
|
|
15
|
+
kind: "info",
|
|
16
|
+
text: `auth: ${meta.authLabel} · cwd: ${meta.cwd}` +
|
|
17
|
+
(meta.vault ? " · vault linked" : "") +
|
|
18
|
+
(meta.mcpSummary ? ` · mcp: ${meta.mcpSummary}` : ""),
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
if (meta.skills.length) {
|
|
22
|
+
banner.push({ id: -2, kind: "info", text: `skills: ${meta.skills.map((s) => "/" + s.name).join(" ")}` });
|
|
23
|
+
}
|
|
24
|
+
banner.push({ id: -1, kind: "info", text: "/help for commands" });
|
|
25
|
+
return banner;
|
|
26
|
+
});
|
|
27
|
+
const [activeText, setActiveText] = useState("");
|
|
28
|
+
const [input, setInput] = useState("");
|
|
29
|
+
const [busy, setBusy] = useState(false);
|
|
30
|
+
const [statusTick, setStatusTick] = useState(0); // re-render statusline after /model etc.
|
|
31
|
+
const [pendingPerm, setPendingPerm] = useState(null);
|
|
32
|
+
const [pendingAsk, setPendingAsk] = useState(null);
|
|
33
|
+
const nextId = useRef(1);
|
|
34
|
+
const activeRef = useRef("");
|
|
35
|
+
const push = useCallback((kind, text) => {
|
|
36
|
+
setItems((prev) => [...prev, { id: nextId.current++, kind, text }]);
|
|
37
|
+
}, []);
|
|
38
|
+
const flushActive = useCallback(() => {
|
|
39
|
+
if (activeRef.current.trim())
|
|
40
|
+
push("assistant", activeRef.current.trimEnd());
|
|
41
|
+
activeRef.current = "";
|
|
42
|
+
setActiveText("");
|
|
43
|
+
}, [push]);
|
|
44
|
+
const runMessage = useCallback(async (message) => {
|
|
45
|
+
setBusy(true);
|
|
46
|
+
try {
|
|
47
|
+
for await (const ev of session.send(message)) {
|
|
48
|
+
switch (ev.type) {
|
|
49
|
+
case "text_delta":
|
|
50
|
+
activeRef.current += ev.text;
|
|
51
|
+
setActiveText(activeRef.current);
|
|
52
|
+
break;
|
|
53
|
+
case "tool_start":
|
|
54
|
+
flushActive();
|
|
55
|
+
push("tool", `⚙ ${ev.summary}`);
|
|
56
|
+
break;
|
|
57
|
+
case "tool_end":
|
|
58
|
+
if (ev.isError)
|
|
59
|
+
push("warn", ` ✗ ${ev.output.split("\n")[0] ?? ""}`);
|
|
60
|
+
break;
|
|
61
|
+
case "permission_denied":
|
|
62
|
+
flushActive();
|
|
63
|
+
push("warn", `✗ denied: ${ev.summary}`);
|
|
64
|
+
break;
|
|
65
|
+
case "turn_end":
|
|
66
|
+
flushActive();
|
|
67
|
+
break;
|
|
68
|
+
case "error":
|
|
69
|
+
flushActive();
|
|
70
|
+
push("error", `error: ${ev.message}`);
|
|
71
|
+
if (ev.code === "window_limit" || ev.code === "weekly_limit") {
|
|
72
|
+
push("warn", "session limit hit, buy a reset from the web UI, or wait for the window to roll");
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
setBusy(false);
|
|
80
|
+
}
|
|
81
|
+
}, [session, push, flushActive]);
|
|
82
|
+
const handleSlash = useCallback((line) => {
|
|
83
|
+
const [cmd = "", ...rest] = line.slice(1).split(/\s+/);
|
|
84
|
+
const arg = rest.join(" ").trim();
|
|
85
|
+
switch (cmd) {
|
|
86
|
+
case "help":
|
|
87
|
+
push("info", "/models /model [id] /mode [default|accept-edits|plan|bypass] /skills /<skill> [args] /clear /usage /exit");
|
|
88
|
+
return;
|
|
89
|
+
case "model":
|
|
90
|
+
if (arg) {
|
|
91
|
+
session.model = arg;
|
|
92
|
+
setStatusTick((t) => t + 1);
|
|
93
|
+
}
|
|
94
|
+
push("info", `model: ${session.model}`);
|
|
95
|
+
return;
|
|
96
|
+
case "mode":
|
|
97
|
+
if (arg && MODES.includes(arg)) {
|
|
98
|
+
session.mode = arg;
|
|
99
|
+
setStatusTick((t) => t + 1);
|
|
100
|
+
}
|
|
101
|
+
push("info", `mode: ${session.mode}`);
|
|
102
|
+
return;
|
|
103
|
+
case "skills":
|
|
104
|
+
push("info", meta.skills.length
|
|
105
|
+
? meta.skills.map((s) => `/${s.name} ${s.description}`).join("\n")
|
|
106
|
+
: "no skills found (~/.9p/skills or .9p/skills)");
|
|
107
|
+
return;
|
|
108
|
+
case "clear":
|
|
109
|
+
session.clear();
|
|
110
|
+
push("info", "context cleared");
|
|
111
|
+
return;
|
|
112
|
+
case "usage": {
|
|
113
|
+
const u = session.usage;
|
|
114
|
+
push("info", `${u.requests} calls · ${u.inputTokens} in (${u.cachedTokens} cached) · ${u.outputTokens} out`);
|
|
115
|
+
const d = session.delegated;
|
|
116
|
+
if (d.calls) {
|
|
117
|
+
push("info", `⇢ ${d.calls} delegated · ${d.contextTokensSaved} tokens kept out of context ` +
|
|
118
|
+
`(worker: ${d.workerUsage.inputTokens} in / ${d.workerUsage.outputTokens} out)`);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
case "exit":
|
|
123
|
+
case "quit":
|
|
124
|
+
exit();
|
|
125
|
+
return;
|
|
126
|
+
default: {
|
|
127
|
+
const skill = meta.skills.find((s) => s.name === cmd);
|
|
128
|
+
if (skill) {
|
|
129
|
+
push("user", line);
|
|
130
|
+
void runMessage(skillMessage(skill, arg));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
push("error", `unknown command: /${cmd}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}, [session, meta.skills, push, exit, runMessage]);
|
|
137
|
+
const submit = useCallback((value) => {
|
|
138
|
+
const line = value.trim();
|
|
139
|
+
setInput("");
|
|
140
|
+
if (!line)
|
|
141
|
+
return;
|
|
142
|
+
if (pendingAsk && !pendingAsk.options.length) {
|
|
143
|
+
const { resolve } = pendingAsk;
|
|
144
|
+
setPendingAsk(null);
|
|
145
|
+
push("info", `answered: ${line}`);
|
|
146
|
+
resolve(line);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (line.startsWith("/")) {
|
|
150
|
+
handleSlash(line);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
push("user", line);
|
|
154
|
+
void runMessage(line);
|
|
155
|
+
}, [pendingAsk, handleSlash, push, runMessage]);
|
|
156
|
+
useInput((char) => {
|
|
157
|
+
if (pendingPerm) {
|
|
158
|
+
const allow = /^y$/i.test(char);
|
|
159
|
+
if (/^[yn]$/i.test(char)) {
|
|
160
|
+
const { resolve, summary } = pendingPerm;
|
|
161
|
+
setPendingPerm(null);
|
|
162
|
+
push(allow ? "info" : "warn", `${allow ? "✓ allowed" : "✗ denied"}: ${summary}`);
|
|
163
|
+
resolve(allow);
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (pendingAsk?.options.length) {
|
|
168
|
+
const idx = Number(char);
|
|
169
|
+
const pick = pendingAsk.options[idx - 1];
|
|
170
|
+
if (pick !== undefined) {
|
|
171
|
+
const { resolve } = pendingAsk;
|
|
172
|
+
setPendingAsk(null);
|
|
173
|
+
push("info", `answered: ${pick}`);
|
|
174
|
+
resolve(pick);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}, { isActive: pendingPerm !== null || (pendingAsk?.options.length ?? 0) > 0 });
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
register?.({
|
|
180
|
+
requestPermission: (summary) => new Promise((resolve) => setPendingPerm({ summary, resolve })),
|
|
181
|
+
requestAnswer: (question, options) => new Promise((resolve) => setPendingAsk({ question, options, resolve })),
|
|
182
|
+
});
|
|
183
|
+
}, [register]);
|
|
184
|
+
const u = session.usage;
|
|
185
|
+
void statusTick;
|
|
186
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item) => (_jsxs(Box, { marginBottom: item.kind === "assistant" || item.kind === "user" ? 1 : 0, children: [item.kind === "banner" && (_jsx(Text, { color: "magenta", bold: true, children: item.text })), item.kind === "user" && _jsx(Text, { color: "cyan", children: `❯ ${item.text}` }), item.kind === "assistant" && _jsx(Text, { children: item.text }), item.kind === "tool" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "warn" && _jsx(Text, { color: "yellow", children: item.text }), item.kind === "error" && _jsx(Text, { color: "red", children: item.text })] }, item.id)) }), activeText !== "" && _jsx(Text, { children: activeText }), pendingPerm && (_jsx(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, children: _jsx(Text, { color: "yellow", children: `● permission: ${pendingPerm.summary}, allow? [y/n]` }) })), pendingAsk && (_jsxs(Box, { borderStyle: "round", borderColor: "magenta", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "magenta", children: `● ${pendingAsk.question}` }), pendingAsk.options.map((o, i) => (_jsx(Text, { dimColor: true, children: ` ${i + 1}. ${o}` }, o))), pendingAsk.options.length > 0 && _jsx(Text, { dimColor: true, children: "press a number to choose" })] })), !pendingPerm && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: busy ? "… " : "9p ❯ " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: submit, focus: !pendingPerm })] })), _jsx(Text, { dimColor: true, children: `${session.model} · ${session.mode} · ${u.requests} calls · ${u.inputTokens} in · ${u.outputTokens} out` +
|
|
187
|
+
(session.delegated.calls
|
|
188
|
+
? ` · ⇢ ${kfmt(session.delegated.contextTokensSaved)} saved`
|
|
189
|
+
: "") })] }));
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=app.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { AgentSession, McpManager, loadSkills, resolveVault, } from "@9thprotocol/agent-core";
|
|
4
|
+
import { requireAuth, DEFAULT_MODEL, fetchCatalog, getAutoBias } from "../shared.js";
|
|
5
|
+
import { App } from "./app.js";
|
|
6
|
+
/** `9p` in a TTY, the Ink TUI host. */
|
|
7
|
+
export async function runTui(cwd) {
|
|
8
|
+
const auth = await requireAuth();
|
|
9
|
+
const mcp = await McpManager.fromCwd(cwd);
|
|
10
|
+
const skills = loadSkills(cwd);
|
|
11
|
+
const vault = resolveVault(cwd);
|
|
12
|
+
let bridge = null;
|
|
13
|
+
const catalog = await fetchCatalog(auth);
|
|
14
|
+
const session = new AgentSession({
|
|
15
|
+
apiKey: auth.apiKey,
|
|
16
|
+
...(auth.platform ? { platform: auth.platform } : {}),
|
|
17
|
+
mcp,
|
|
18
|
+
model: process.env.NINEP_MODEL ?? DEFAULT_MODEL,
|
|
19
|
+
autoRouter: { bias: getAutoBias(), ...(catalog ? { catalog } : {}) },
|
|
20
|
+
cwd,
|
|
21
|
+
mode: "default",
|
|
22
|
+
decide: async (req) => (bridge ? bridge.requestPermission(req.summary) : false),
|
|
23
|
+
askUser: async (q) => bridge ? bridge.requestAnswer(q.question, q.options ?? []) : "",
|
|
24
|
+
});
|
|
25
|
+
const { waitUntilExit } = render(_jsx(App, { session: session, meta: {
|
|
26
|
+
cwd,
|
|
27
|
+
authLabel: auth.platform ? "platform" : "byok",
|
|
28
|
+
vault,
|
|
29
|
+
skills,
|
|
30
|
+
mcpSummary: mcp.servers.length ? `${mcp.servers.join(",")} (${mcp.tools.length})` : null,
|
|
31
|
+
}, register: (b) => (bridge = b) }));
|
|
32
|
+
await waitUntilExit();
|
|
33
|
+
await mcp.close();
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=index.js.map
|
package/dist/webui.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML for the local session (`9p serve`) and the desktop app window.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately one self-contained file with no build step and no dependencies:
|
|
5
|
+
* it is served by the CLI itself, so a bundler here would mean a second build
|
|
6
|
+
* pipeline for a single page.
|
|
7
|
+
*
|
|
8
|
+
* Layout: a full-height left sidebar, a breadcrumb bar over the main column,
|
|
9
|
+
* and a composer that owns its own controls. The sidebar runs to the top of the
|
|
10
|
+
* window on purpose. On macOS the desktop app hides the title bar, so the
|
|
11
|
+
* traffic lights sit in the sidebar's header strip instead of over the content.
|
|
12
|
+
*/
|
|
13
|
+
export declare function chatHtml(): string;
|