@crievo/omni 0.1.6 → 0.1.8
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/omnivideo.config.example.json +1 -0
- package/package.json +1 -1
- package/reference/cloud.md +15 -1
- package/reference/learnings.md +7 -0
- package/reference/studio.md +1 -0
- package/studio/api/app.ts +4 -0
- package/studio/api/routes/cost.ts +13 -0
- package/studio/api/routes/me.ts +8 -0
- package/studio/api/services/agent-tools.ts +2 -2
- package/studio/api/services/cost.ts +22 -0
- package/studio/api/services/me.ts +67 -0
- package/studio/api/services/sessions.ts +8 -2
- package/studio/runner/runner.ts +57 -10
- package/studio/shared/omni-screens.ts +1 -1
- package/studio/shared/schemas/cost.ts +10 -0
- package/studio/shared/types/api.ts +74 -0
- package/studio/studio.ts +27 -2
- package/studio/web/components/AppShell.tsx +11 -3
- package/studio/web/components/FirstConnection.tsx +222 -0
- package/studio/web/components/OfflineNotice.tsx +59 -0
- package/studio/web/components/SetupSteps.tsx +63 -0
- package/studio/web/components/Sidebar.tsx +10 -25
- package/studio/web/components/StudioOffline.tsx +48 -50
- package/studio/web/components/UserCard.tsx +79 -0
- package/studio/web/components/icons.tsx +2 -0
- package/studio/web/hooks/useComputers.ts +55 -0
- package/studio/web/hooks/useCost.ts +11 -0
- package/studio/web/hooks/useMe.ts +32 -0
- package/studio/web/hooks/useSetupProgress.ts +54 -0
- package/studio/web/lib/me.ts +17 -0
- package/studio/web/lib/query-client.ts +6 -0
- package/studio/web/lib/settings-categories.ts +2 -2
- package/studio/web/lib/setup-retry.ts +29 -0
- package/studio/web/lib/studio-offline.ts +105 -59
- package/studio/web/pages/ConnectPreviewPage/index.tsx +215 -0
- package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +18 -9
- package/studio/web/pages/SettingsPage/components/AgentCostPanel.tsx +253 -0
- package/studio/web/pages/SettingsPage/components/ComputersPanel.tsx +145 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +2 -2
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +3 -3
- package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +7 -2
- package/studio/web/pages/SettingsPage/index.tsx +15 -1
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +5 -2
- package/studio/web/router.tsx +8 -0
- package/studio/web/styles/components/setup-steps.css +163 -0
- package/studio/web/styles/components/user-card.css +121 -0
- package/studio/web/styles/index.css +2 -0
- package/studio/web/styles/pages/settings/shell.css +79 -0
- package/studio/web/styles/pages/settings/usage.css +47 -0
- package/studio/web/styles/pages/studio-offline.css +224 -38
- package/tools/cost.mjs +114 -0
- package/tools/lib/agents.mjs +9 -3
- package/tools/lib/channel.mjs +1 -1
- package/tools/lib/cloud-user.mjs +58 -0
- package/tools/lib/cost.mjs +357 -0
- package/tools/lib/deps.mjs +27 -12
- package/tools/lib/oauth-client.mjs +28 -14
- package/tools/lib/pages-style.mjs +67 -0
- package/tools/lib/pairing.mjs +1 -1
- package/tools/lib/schedule/schedule.mjs +1 -1
- package/tools/lib/self-update.mjs +122 -0
- package/tools/lib/setup-progress.mjs +315 -0
- package/tools/lib/up.mjs +176 -61
- package/tools/metrics.mjs +6 -1
- package/tools/omni.mjs +41 -16
- package/tools/omnichat.mjs +45 -9
- package/tools/platforms.mjs +75 -14
- package/tools/runner.mjs +6 -6
package/package.json
CHANGED
package/reference/cloud.md
CHANGED
|
@@ -39,7 +39,7 @@ and the studio talk to the Worker with `OMNICHAT_CLOUD_SECRET` (the Worker's `SY
|
|
|
39
39
|
back if it stops, log in `.runner/studio.log`; `service restart` after a `git pull`). Setup:
|
|
40
40
|
`node cloud/studio/setup-auth.mjs` (tables and secrets), then `npx wrangler deploy` in `cloud/studio`.
|
|
41
41
|
Every write that goes through to the Mac is logged (email, method, path, status; the last 2000, at
|
|
42
|
-
`<studio>/
|
|
42
|
+
Configurações → Agente de IA, from `<studio>/session/audit`) and limited to 120 a minute per user (429 beyond it); a runner and a hub on
|
|
43
43
|
different protocol numbers refuse each other and say which side is old.
|
|
44
44
|
Protocol in `tools/lib/channel.mjs`, plan in `docs/specs/phase-1-split-api.md`.
|
|
45
45
|
- **Each job has an off switch in the database** (`settings`: `active`, `metrics_active`,
|
|
@@ -55,6 +55,20 @@ and the studio talk to the Worker with `OMNICHAT_CLOUD_SECRET` (the Worker's `SY
|
|
|
55
55
|
|
|
56
56
|
Turning back: the flag to `false` and the push again (the cloud stops), and the local agent on.
|
|
57
57
|
|
|
58
|
+
## The per-user cloud (Phase 3, in progress)
|
|
59
|
+
|
|
60
|
+
The jobs above are moving from the shared `omnichat` Worker into one Durable Object per user, `UserCloud` in the studio
|
|
61
|
+
Worker (`cloud/studio/src/cloud-*.mjs`), with their own config, secrets, webhook (`/hook/<cloudId>`), media
|
|
62
|
+
(`u/<cloudId>/` in R2) and one alarm in place of the crons and the KV gate. Step 2 (the jobs inside the object) is built and
|
|
63
|
+
tested but not deployed or used by a machine yet; the `omnichat` Worker above still does the real work until steps 3–4.
|
|
64
|
+
Routes, flags and what differs from the sketch: `docs/specs/phase-3-per-user-cloud.md`, "Step 2, as built".
|
|
65
|
+
|
|
66
|
+
On the machine, `cloud.user: true` (with `cloud.studio_url`, signed in with the Omni login) points the omnichat, the
|
|
67
|
+
Instagram account and the metrics at that cloud instead of the Worker; publishing (the schedule, the media published
|
|
68
|
+
now, the YouTube/TikTok credentials) stays on the Worker until the last step. Off by default: turning it on points the
|
|
69
|
+
CLIs at an empty cloud, so see "Step 3, as built so far" in the spec for the order (push first, and the Worker stops
|
|
70
|
+
answering before the new cloud starts).
|
|
71
|
+
|
|
58
72
|
## Free plans and their limits
|
|
59
73
|
|
|
60
74
|
- **Workers Free:** 10 ms of CPU and 50 subrequests per run. That is why a cloud pass sends at most 5 DMs
|
package/reference/learnings.md
CHANGED
|
@@ -814,3 +814,10 @@ these voices).
|
|
|
814
814
|
- **Symptom:** none yet; found reading the queue for a Windows port.
|
|
815
815
|
- **Cause:** on Windows, Python's `os.kill(pid, sig)` treats `sig == 0` as `CTRL_C_EVENT` and sends it to the process group of `pid`; any other number is a `TerminateProcess`. The Node `process.kill(pid, 0)` is a real probe on both systems.
|
|
816
816
|
- **Fixed:** `tools/job_queue.py` asks the kernel (`OpenProcess` + `GetExitCodeProcess`, `STILL_ACTIVE`) on Windows and keeps `os.kill` plus the `ps` zombie check elsewhere. Also on Windows: `select()` works on sockets only (the guardian reads the Node pipe in a thread), an atomic replace fails while a reader has the file open (retry), and a text file is written in the code page unless `encoding="utf-8"` says otherwise.
|
|
817
|
+
|
|
818
|
+
## A turn's `total_cost_usd` is the session's running total, not the turn's (18/10/2026)
|
|
819
|
+
|
|
820
|
+
- **Symptom:** the studio's session list showed US$ 220,93 for the Grok video, whose last turn closed at US$ 53,79. Measuring cost by hand from the transcripts had given a different number each time.
|
|
821
|
+
- **Cause:** `claude -p --resume` keeps adding to the conversation's totals. The `result` of turn N carries `total_cost_usd`, `modelUsage[].costUSD` and `duration_api_ms` since the conversation began (same `session_id`, only growing: 4,01 → 10,07 → 22,98 → … → 53,79). Summing the turns' costs counts the early turns again and again. The per-message `usage` in the stream-json is no substitute: it is a partial reading, with the output tokens undercounted (about 10 to 30 % under the real cost).
|
|
822
|
+
- **Fixed:** `tools/lib/cost.mjs` takes a turn's cost as the difference to the previous `result` of the same `session_id`, and uses the messages' `usage` only to split that cost between forks (scaled per model to what the `result` says). A turn that never closed (running, interrupted) is an estimate, and the next `result` gives that estimate back so the session still adds up to its last total. `summarizeSession` counts each conversation once, by its last total. Rule: to measure what an agent spent, run `node tools/cost.mjs`; do not add up `result` costs by hand, and count messages by `message.id`, never by transcript line.
|
|
823
|
+
|
package/reference/studio.md
CHANGED
|
@@ -594,6 +594,7 @@ comes, that it be the studio's own.
|
|
|
594
594
|
| `POST /api/videos/<slug>/sound` | `{ places, bed }` → `sound` on the beats, `transition.sound` and `bed` in `scenes.json` (validated; defaults are dropped) |
|
|
595
595
|
| `POST /api/videos/<slug>/sound/reassemble` | runs `assemble.mjs` (waits for its turn in the queue) |
|
|
596
596
|
| `GET /kit/<name>` | MP3 from the global kit (`kit/sfx/`), for the waveform |
|
|
597
|
+
| `GET /api/cost` | `tools/lib/cost.mjs`: what the Claude sessions spent, by video, stage, fork, model and day (`?days=N`); reads `.sessions/`, cache in `.cost/` |
|
|
597
598
|
| `GET /api/usage` | `tools/usage.py summary --json`: Fish balance, Cartesia quota, usage per video and latest generations (only reads the local log) |
|
|
598
599
|
| `POST /api/usage/refresh` | the same with `--live`: asks the platforms now and writes the reading to `.usage/` |
|
|
599
600
|
| `GET /api/analytics` | `tools/analysis.mjs context` (account, medians at the current reading and at the 24 h and 72 h milestones, also per thousand views, the channel's hour-by-hour `progression` and the `publications` in it, the playbook's `before` range, each Reel with metrics, strategy and hook, the agent) plus the text of the latest channel analysis |
|
package/studio/api/app.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { onboarding } from "./routes/onboarding";
|
|
|
28
28
|
import { publish } from "./routes/publish";
|
|
29
29
|
import { queue } from "./routes/queue";
|
|
30
30
|
import { references } from "./routes/references";
|
|
31
|
+
import { me } from "./routes/me";
|
|
31
32
|
import { runner } from "./routes/runner";
|
|
32
33
|
import { schedule } from "./routes/schedule";
|
|
33
34
|
import { services } from "./routes/services";
|
|
@@ -36,6 +37,7 @@ import { settings } from "./routes/settings";
|
|
|
36
37
|
import { sound, SOUND_UPLOAD_PATH } from "./routes/sound";
|
|
37
38
|
import { thumbnail } from "./routes/thumbnail";
|
|
38
39
|
import { usage } from "./routes/usage";
|
|
40
|
+
import { cost } from "./routes/cost";
|
|
39
41
|
import { videos } from "./routes/videos";
|
|
40
42
|
import { voices } from "./routes/voices";
|
|
41
43
|
|
|
@@ -63,8 +65,10 @@ export function createApp({ port }: AppOptions) {
|
|
|
63
65
|
.route("/", references)
|
|
64
66
|
.route("/", queue)
|
|
65
67
|
.route("/", runner)
|
|
68
|
+
.route("/", me)
|
|
66
69
|
.route("/", services)
|
|
67
70
|
.route("/", usage)
|
|
71
|
+
.route("/", cost)
|
|
68
72
|
.route("/", analytics)
|
|
69
73
|
.route("/", settings)
|
|
70
74
|
.route("/", clone)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* Agent cost (settings Uso e custo): what the Claude sessions spent, by video, stage and model. */
|
|
2
|
+
|
|
3
|
+
import { router } from "../lib/hono";
|
|
4
|
+
import { shaped } from "../lib/result";
|
|
5
|
+
import { query } from "../lib/validate";
|
|
6
|
+
import { getCost } from "../services/cost";
|
|
7
|
+
import { costQuery } from "../../shared/schemas/cost";
|
|
8
|
+
import type { AgentCost } from "../../shared/types/api";
|
|
9
|
+
|
|
10
|
+
export const cost = router().get("/cost", query(costQuery), async c => {
|
|
11
|
+
const days = Number(c.req.valid("query").days) || null;
|
|
12
|
+
return c.json(shaped<AgentCost>(await getCost(days)));
|
|
13
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* Who is using the studio (services/me.ts), for the menu's user card at 127.0.0.1, and its sign-out. */
|
|
2
|
+
|
|
3
|
+
import { router } from "../lib/hono";
|
|
4
|
+
import { getMe, signOutOmni } from "../services/me";
|
|
5
|
+
|
|
6
|
+
export const me = router()
|
|
7
|
+
.get("/me", async c => c.json({ me: await getMe() }))
|
|
8
|
+
.post("/me/logout", async c => c.json(await signOutOmni()));
|
|
@@ -853,7 +853,7 @@ const SCHEDULE_STATE: Record<string, string> = {
|
|
|
853
853
|
scheduled: "agendado",
|
|
854
854
|
publishing: "publicando agora",
|
|
855
855
|
failed: "falhou",
|
|
856
|
-
missed: "perdido (
|
|
856
|
+
missed: "perdido (a máquina estava desligada)",
|
|
857
857
|
};
|
|
858
858
|
|
|
859
859
|
export async function toolSchedule(from?: string, to?: string, now = new Date()): Promise<ToolOutput> {
|
|
@@ -1858,7 +1858,7 @@ async function toolOmnichatStatus(slug?: string, content?: "video" | "carousel")
|
|
|
1858
1858
|
account: st.account?.ready ? { username: st.account.username ?? null } : { problem: st.account?.reason ?? null },
|
|
1859
1859
|
comments_permission: st.comments?.ready ?? null,
|
|
1860
1860
|
/* who answers the comments: the cloud Worker, or this Mac's launchd agent */
|
|
1861
|
-
answered_by: st.agent?.cloud?.ready ? "nuvem" : st.agent?.loaded ? "
|
|
1861
|
+
answered_by: st.agent?.cloud?.ready ? "nuvem" : st.agent?.loaded ? "esta máquina" : "ninguém (agente parado)",
|
|
1862
1862
|
pause: st.pause,
|
|
1863
1863
|
rules: (st.rules ?? []).map((r: any) => ({
|
|
1864
1864
|
content: r.type,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* What the agents spent on the videos. The accounting lives in tools/lib/cost.mjs, which
|
|
2
|
+
tools/cost.mjs (the terminal) also uses; the studio only shows what it returns.
|
|
3
|
+
A finished turn is cached on disk, but the open ones are read again, so the answer is kept for
|
|
4
|
+
a few seconds: the UI reloads on every queue change. */
|
|
5
|
+
|
|
6
|
+
import { readTurns, summarize } from "../../../tools/lib/cost.mjs";
|
|
7
|
+
|
|
8
|
+
const cached = new Map<string, { at: number; data: Promise<unknown> }>();
|
|
9
|
+
|
|
10
|
+
export function getCost(days: number | null) {
|
|
11
|
+
const key = String(days ?? "all");
|
|
12
|
+
const hit = cached.get(key);
|
|
13
|
+
if (hit && Date.now() - hit.at < 5000) return hit.data;
|
|
14
|
+
const data = readTurns({ since: days ? Date.now() - days * 86_400_000 : null }).then(rows => ({
|
|
15
|
+
days,
|
|
16
|
+
...summarize(rows),
|
|
17
|
+
}));
|
|
18
|
+
cached.set(key, { at: Date.now(), data });
|
|
19
|
+
/* a failed reading is not kept */
|
|
20
|
+
data.catch(() => cached.delete(key));
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* Who is using the studio, for the menu's user card (GET /api/me). The hosted page doesn't ask this: the Worker
|
|
2
|
+
answers /session/me from the signed-in session, even while this computer is away. Here, at 127.0.0.1, it is
|
|
3
|
+
the account signed in to Omni on this computer (the plugin's login: the Worker's /account), else the Claude
|
|
4
|
+
Code account that runs the agent (its name and email, read from its own files), which also fills in a name the
|
|
5
|
+
Omni account doesn't carry yet. Without either, nobody:
|
|
6
|
+
the card hides. Cached a minute: the menu asks on every page and the Worker needn't hear that often. */
|
|
7
|
+
|
|
8
|
+
import { accountsOf, loginOf } from "../../../tools/lib/agents.mjs";
|
|
9
|
+
import { accessToken, logout, readTokens } from "../../../tools/lib/oauth-client.mjs";
|
|
10
|
+
import { cloudSettings } from "../../../tools/platforms.mjs";
|
|
11
|
+
import type { Me } from "../../shared/types/api";
|
|
12
|
+
import { readConfig } from "./settings";
|
|
13
|
+
|
|
14
|
+
const TTL_MS = 60_000;
|
|
15
|
+
let cached: { at: number; me: Me | null } | null = null;
|
|
16
|
+
|
|
17
|
+
/* the Omni account of this computer: its login's token trades for the account at the Worker */
|
|
18
|
+
async function omniAccount(): Promise<Me | null> {
|
|
19
|
+
const url = cloudSettings().studio.url;
|
|
20
|
+
if (!url) return null;
|
|
21
|
+
try {
|
|
22
|
+
const token = await accessToken(url);
|
|
23
|
+
if (!token) return null;
|
|
24
|
+
const r = await fetch(`${url}/account`, {
|
|
25
|
+
headers: { authorization: `Bearer ${token}` },
|
|
26
|
+
signal: AbortSignal.timeout(4000),
|
|
27
|
+
});
|
|
28
|
+
if (!r.ok) return null;
|
|
29
|
+
const a = (await r.json()) as { email?: string | null; name?: string | null; image?: string | null };
|
|
30
|
+
return a.email ? { name: a.name ?? null, email: a.email, image: a.image ?? null, source: "omni" } : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* the Claude Code account the agent runs on */
|
|
37
|
+
function claudeAccount(): Me | null {
|
|
38
|
+
for (const a of accountsOf(readConfig() ?? {}) as { provider: string }[]) {
|
|
39
|
+
if (a.provider !== "claude") continue;
|
|
40
|
+
const login = loginOf(a) as { email: string | null; name?: string | null } | null;
|
|
41
|
+
if (login?.email) return { name: login.name ?? null, email: login.email, image: null, source: "claude" };
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function getMe(now = Date.now()): Promise<Me | null> {
|
|
47
|
+
if (cached && now - cached.at < TTL_MS) return cached.me;
|
|
48
|
+
const claude = claudeAccount();
|
|
49
|
+
const omni = await omniAccount();
|
|
50
|
+
/* the Worker may not know the name yet: the Claude Code account of the same email does */
|
|
51
|
+
const me = omni ? { ...omni, name: omni.name ?? (claude?.email === omni.email ? claude.name : null) } : claude;
|
|
52
|
+
cached = { at: now, me };
|
|
53
|
+
return me;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Sair da conta Omni deste computador: the same as `omni logout`. The Worker is told to end this computer's login
|
|
57
|
+
and the tokens here are forgotten; the card then falls back to the Claude Code account, or hides. */
|
|
58
|
+
export async function signOutOmni(): Promise<{ ok: boolean; output?: string; error?: string }> {
|
|
59
|
+
const url = cloudSettings().studio.url;
|
|
60
|
+
if (!url || !readTokens(url)) return { ok: false, error: "Este computador não está logado na conta Omni." };
|
|
61
|
+
const confirmed = await logout(url);
|
|
62
|
+
cached = null;
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
output: confirmed ? "Saiu da conta Omni." : "Saiu da conta Omni aqui; o studio hospedado não confirmou.",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -336,7 +336,13 @@ function stateOf(r: SessionRecord): { state: SessionState; result: TurnResult |
|
|
|
336
336
|
export function summarizeSession(r: SessionRecord) {
|
|
337
337
|
const { state, result, now } = stateOf(r);
|
|
338
338
|
const t = r.turns.at(-1);
|
|
339
|
-
|
|
339
|
+
/* a turn's `cost` is the conversation's running total (--resume keeps adding to it), so a
|
|
340
|
+
conversation counts once, by its last total; a handoff to another account is a new one */
|
|
341
|
+
const running = new Map<string, number>();
|
|
342
|
+
for (const x of r.turns) {
|
|
343
|
+
const cost = turnInfo(r, x).result?.cost;
|
|
344
|
+
if (cost != null) running.set(x.engine_id ?? r.id, Math.max(running.get(x.engine_id ?? r.id) ?? 0, cost));
|
|
345
|
+
}
|
|
340
346
|
const account = t ? accountOfTurn(r, t) : null;
|
|
341
347
|
let activity = Date.parse(r.created_at);
|
|
342
348
|
try {
|
|
@@ -355,7 +361,7 @@ export function summarizeSession(r: SessionRecord) {
|
|
|
355
361
|
turns: r.turns.length,
|
|
356
362
|
turn: t ? { n: t.n, origin: t.origin, prompt: t.prompt, started_at: t.started_at, pid: t.pid, result } : null,
|
|
357
363
|
now,
|
|
358
|
-
total_cost: +
|
|
364
|
+
total_cost: +[...running.values()].reduce((a, b) => a + b, 0).toFixed(4),
|
|
359
365
|
agent: t ? { provider: providerOf(t), account: account?.id ?? null, label: account?.label ?? null } : null,
|
|
360
366
|
};
|
|
361
367
|
}
|
package/studio/runner/runner.ts
CHANGED
|
@@ -25,6 +25,9 @@ const DEAD_MS = 75_000;
|
|
|
25
25
|
const KEY_CHECK_MS = 30_000;
|
|
26
26
|
/* a refused key pairs again by itself at most this often (the approval page opens on this Mac) */
|
|
27
27
|
const PAIR_EVERY_MS = 60 * 60_000;
|
|
28
|
+
/* with no login (never entered, signed out, or disconnected at the studio) the approval page opens on this computer by
|
|
29
|
+
itself, at most this often: it asks for a click, and someone who closed it didn't want it every hour */
|
|
30
|
+
const LOGIN_EVERY_MS = 6 * 60 * 60_000;
|
|
28
31
|
|
|
29
32
|
export type RunnerOptions = {
|
|
30
33
|
/** the hosted studio, https://… */
|
|
@@ -42,6 +45,18 @@ export type RunnerOptions = {
|
|
|
42
45
|
/** pairs this Mac again (opens the approval page, waits, saves the new key): the key, or null. Called when
|
|
43
46
|
* the cloud refuses the key, never after a "Desconectar", which is the user's own decision */
|
|
44
47
|
pair?: () => Promise<string | null>;
|
|
48
|
+
/** signs this computer in again: opens the approval page in the browser and waits for the user to approve
|
|
49
|
+
* (tools/lib/oauth-client.mjs login). Called when there is no usable login, so nobody has to open a terminal or
|
|
50
|
+
* Claude Code to get back in; the user only approves */
|
|
51
|
+
login?: () => Promise<void>;
|
|
52
|
+
/** the hub says this machine and the hosted studio speak different protocols (UPGRADE). Which side is old isn't
|
|
53
|
+
* in the close reason, so whoever answers this looks (tools/lib/self-update.mjs: is there a newer version of
|
|
54
|
+
* the CLI?) and updates this machine when it is the one behind. Called on every retry, a minute apart. */
|
|
55
|
+
onOutdated?: () => void | Promise<void>;
|
|
56
|
+
/** the waits, in ms; the tests shorten them */
|
|
57
|
+
keyCheckMs?: number;
|
|
58
|
+
loginEveryMs?: number;
|
|
59
|
+
protocolRetryMs?: number;
|
|
45
60
|
};
|
|
46
61
|
|
|
47
62
|
/** Range on a media response: 206 from a Blob slice, 416 out of bounds; the rest as it is. */
|
|
@@ -107,6 +122,10 @@ export function startRunner(o: RunnerOptions) {
|
|
|
107
122
|
let hinted = false;
|
|
108
123
|
let pairing = false;
|
|
109
124
|
let pairedAt = 0;
|
|
125
|
+
const keyCheckMs = o.keyCheckMs ?? KEY_CHECK_MS;
|
|
126
|
+
const loginEveryMs = o.loginEveryMs ?? LOGIN_EVERY_MS;
|
|
127
|
+
let signingIn = false;
|
|
128
|
+
let signedInAt = 0;
|
|
110
129
|
|
|
111
130
|
const serve = async (request: Request) => withRange(request, await o.handle(asLocal(request, o.port)));
|
|
112
131
|
|
|
@@ -114,9 +133,10 @@ export function startRunner(o: RunnerOptions) {
|
|
|
114
133
|
/* an unreachable studio while refreshing says nothing about the login: try again later */
|
|
115
134
|
const token = await Promise.resolve(o.token()).catch(() => "");
|
|
116
135
|
if (!token || token === revoked) {
|
|
117
|
-
if (!hinted) log("aviso:
|
|
136
|
+
if (!hinted) log("aviso: esta máquina não está conectada ao studio hospedado");
|
|
118
137
|
hinted = true;
|
|
119
|
-
|
|
138
|
+
void signIn();
|
|
139
|
+
timer = setTimeout(connect, keyCheckMs);
|
|
120
140
|
return;
|
|
121
141
|
}
|
|
122
142
|
hinted = false;
|
|
@@ -171,17 +191,20 @@ export function startRunner(o: RunnerOptions) {
|
|
|
171
191
|
/* the key was deleted at /runner: wait for a new pairing instead of knocking with the old key */
|
|
172
192
|
if (e.code === REVOKED) {
|
|
173
193
|
revoked = token;
|
|
174
|
-
log("aviso:
|
|
194
|
+
log("aviso: esta máquina foi desconectada do studio hospedado");
|
|
175
195
|
hinted = true;
|
|
176
|
-
|
|
196
|
+
/* the next look finds no login and opens the approval page (once): the user can approve or refuse it */
|
|
197
|
+
timer = setTimeout(connect, keyCheckMs);
|
|
177
198
|
return;
|
|
178
199
|
}
|
|
179
200
|
/* the hub and this Mac speak different protocols: say which side is old, and look again in a minute
|
|
180
201
|
(the studio may be redeployed, or this Mac updated, without a restart) */
|
|
181
202
|
if (e.code === UPGRADE) {
|
|
182
|
-
if (!warned)
|
|
203
|
+
if (!warned)
|
|
204
|
+
log(`aviso: esta máquina e o studio hospedado não se entendem (${e.reason}); tento de novo em 1 min`);
|
|
183
205
|
warned = true;
|
|
184
|
-
|
|
206
|
+
void Promise.resolve(o.onOutdated?.()).catch(() => {});
|
|
207
|
+
timer = setTimeout(connect, o.protocolRetryMs ?? 60_000);
|
|
185
208
|
return;
|
|
186
209
|
}
|
|
187
210
|
if (was || !warned) {
|
|
@@ -200,6 +223,30 @@ export function startRunner(o: RunnerOptions) {
|
|
|
200
223
|
/* onclose follows */
|
|
201
224
|
};
|
|
202
225
|
};
|
|
226
|
+
/* No usable login: opens the approval page on this computer and waits for the user to approve, then connects with
|
|
227
|
+
the login it stored. At most once in a while, and never twice at the same time. */
|
|
228
|
+
const signIn = async () => {
|
|
229
|
+
if (!o.login || stopped || signingIn) return;
|
|
230
|
+
if (signedInAt && Date.now() - signedInAt < loginEveryMs) return;
|
|
231
|
+
signingIn = true;
|
|
232
|
+
signedInAt = Date.now();
|
|
233
|
+
log("aviso: abri a página de aprovação no navegador desta máquina: aprove para reconectar");
|
|
234
|
+
try {
|
|
235
|
+
await o.login();
|
|
236
|
+
log("cloud: login refeito");
|
|
237
|
+
delay = 1000;
|
|
238
|
+
hinted = false;
|
|
239
|
+
revoked = "";
|
|
240
|
+
if (!stopped) {
|
|
241
|
+
if (timer) clearTimeout(timer);
|
|
242
|
+
void connect();
|
|
243
|
+
}
|
|
244
|
+
} catch (e) {
|
|
245
|
+
log(`aviso: o login não terminou (${(e as Error).message})`);
|
|
246
|
+
} finally {
|
|
247
|
+
signingIn = false;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
203
250
|
/* The key was refused (the login was redone, the key went missing in the cloud…): pair again, which opens
|
|
204
251
|
the approval page on this Mac, and connect with the new key. True when it took over the retry. */
|
|
205
252
|
const repair = async (token: string): Promise<boolean> => {
|
|
@@ -208,13 +255,13 @@ export function startRunner(o: RunnerOptions) {
|
|
|
208
255
|
if (k.valid !== false) return false;
|
|
209
256
|
revoked = token;
|
|
210
257
|
if (Date.now() - pairedAt < PAIR_EVERY_MS) {
|
|
211
|
-
timer = setTimeout(connect,
|
|
258
|
+
timer = setTimeout(connect, keyCheckMs);
|
|
212
259
|
return true;
|
|
213
260
|
}
|
|
214
261
|
pairing = true;
|
|
215
262
|
pairedAt = Date.now();
|
|
216
263
|
log(
|
|
217
|
-
"aviso: o studio hospedado recusou a chave
|
|
264
|
+
"aviso: o studio hospedado recusou a chave desta máquina; abri a página de aprovação no navegador: aprove para reconectar",
|
|
218
265
|
);
|
|
219
266
|
const key = await o.pair!().catch(e => {
|
|
220
267
|
log(
|
|
@@ -225,11 +272,11 @@ export function startRunner(o: RunnerOptions) {
|
|
|
225
272
|
pairing = false;
|
|
226
273
|
if (stopped) return true;
|
|
227
274
|
if (key) {
|
|
228
|
-
log("cloud:
|
|
275
|
+
log("cloud: máquina pareada de novo");
|
|
229
276
|
delay = 1000;
|
|
230
277
|
hinted = false;
|
|
231
278
|
void connect();
|
|
232
|
-
} else timer = setTimeout(connect,
|
|
279
|
+
} else timer = setTimeout(connect, keyCheckMs);
|
|
233
280
|
return true;
|
|
234
281
|
};
|
|
235
282
|
|
|
@@ -39,7 +39,7 @@ export const SCREENS: Record<ScreenId, { label: string; path: string }> = {
|
|
|
39
39
|
/* where a content's Omnichat is set up, always: /omnichat/video/<slug> (or carousel) */
|
|
40
40
|
omnichat_rule: { label: "Omnichat de um conteúdo", path: "/omnichat/$kind/$id" },
|
|
41
41
|
settings: { label: "Configurações", path: "/settings" },
|
|
42
|
-
usage: { label: "Uso
|
|
42
|
+
usage: { label: "Uso e custo", path: "/usage" },
|
|
43
43
|
omni_measures: { label: "Medições do Omni", path: "/agent" },
|
|
44
44
|
getting_started: { label: "Primeiros passos", path: "/getting-started" },
|
|
45
45
|
};
|
|
@@ -939,6 +939,44 @@ export type UsageData = {
|
|
|
939
939
|
|
|
940
940
|
export type Usage = UsageData | { error: string };
|
|
941
941
|
|
|
942
|
+
/* what the agents spent (tools/lib/cost.mjs): the Claude turns in .sessions/, by video, stage and model */
|
|
943
|
+
export type AgentCostVideo = {
|
|
944
|
+
video: string;
|
|
945
|
+
type: string;
|
|
946
|
+
total: number;
|
|
947
|
+
turns: number;
|
|
948
|
+
first: string;
|
|
949
|
+
last: string;
|
|
950
|
+
stages: Record<string, number>;
|
|
951
|
+
models: Record<string, number>;
|
|
952
|
+
};
|
|
953
|
+
export type AgentCostFork = {
|
|
954
|
+
video: string;
|
|
955
|
+
session: string;
|
|
956
|
+
turn: number;
|
|
957
|
+
stage: string;
|
|
958
|
+
label: string;
|
|
959
|
+
cost: number;
|
|
960
|
+
messages: number;
|
|
961
|
+
};
|
|
962
|
+
export type AgentCost = {
|
|
963
|
+
/* the window in days; null = everything recorded */
|
|
964
|
+
days: number | null;
|
|
965
|
+
total: number;
|
|
966
|
+
turns: number;
|
|
967
|
+
sessions: number;
|
|
968
|
+
/* turns that never closed: their cost is an estimate */
|
|
969
|
+
partial: number;
|
|
970
|
+
/* turns with no dollar cost (Codex) */
|
|
971
|
+
unmeasured: number;
|
|
972
|
+
by_kind: Record<string, number>;
|
|
973
|
+
by_stage: Record<string, number>;
|
|
974
|
+
by_model: Record<string, number>;
|
|
975
|
+
by_day: { day: string; cost: number }[];
|
|
976
|
+
by_video: AgentCostVideo[];
|
|
977
|
+
top_forks: AgentCostFork[];
|
|
978
|
+
};
|
|
979
|
+
|
|
942
980
|
/* --- settings ---------------------------------------------------------------------------- */
|
|
943
981
|
export type KeyStatus = { name: string; masked: string | null; in_env: boolean };
|
|
944
982
|
|
|
@@ -1184,6 +1222,42 @@ export type RunnerProblem =
|
|
|
1184
1222
|
| { reason: "protocol"; at: string; side: "mac" | "cloud"; host?: string }
|
|
1185
1223
|
| { reason: "revoked"; at: string; name?: string };
|
|
1186
1224
|
|
|
1225
|
+
/* the first-time setup the CLI reports to the user's cloud while the studio isn't connected yet
|
|
1226
|
+
(tools/lib/setup-progress.mjs; read from GET /api/cloud/config, key `setup`) */
|
|
1227
|
+
export type SetupStepState = "pending" | "running" | "done" | "error";
|
|
1228
|
+
export type SetupStep = { id: string; label: string; detail: string; state: SetupStepState; error?: string };
|
|
1229
|
+
export type SetupRecord = {
|
|
1230
|
+
v: 1;
|
|
1231
|
+
state: "running" | "done" | "error";
|
|
1232
|
+
device: string;
|
|
1233
|
+
started_at: number;
|
|
1234
|
+
updated_at: number;
|
|
1235
|
+
steps: SetupStep[];
|
|
1236
|
+
/* the last time the process waiting for a retry looked (the page offers "Tentar de novo" while it is recent) and the
|
|
1237
|
+
id of the last retry request it answered */
|
|
1238
|
+
watching_at?: number;
|
|
1239
|
+
handled?: string;
|
|
1240
|
+
};
|
|
1241
|
+
/* what the page writes to ask for a retry (`setup_retry` of the cloud config): a step id, or "all" */
|
|
1242
|
+
export type SetupRetryRequest = { id: string; step: string; at: number };
|
|
1243
|
+
|
|
1244
|
+
/* who is using the studio: the menu's user card (GET /api/me here; the Worker's /session/me when hosted).
|
|
1245
|
+
`source` is where a local studio found them: the Omni login, or the Claude Code account */
|
|
1246
|
+
export type Me = { name: string | null; email: string; image: string | null; source: "omni" | "claude" | "session" };
|
|
1247
|
+
|
|
1248
|
+
/* the computers signed in to the hosted studio, and the actions that went through to them
|
|
1249
|
+
(the Worker's /session/computers and /session/audit: cloud/studio/src/account-routes.mjs) */
|
|
1250
|
+
export type Computer = {
|
|
1251
|
+
id: string;
|
|
1252
|
+
name: string;
|
|
1253
|
+
/* "login": entered with the plugin; "key": the old pairing key (tools/runner.mjs) */
|
|
1254
|
+
kind: "login" | "key";
|
|
1255
|
+
created_at: number;
|
|
1256
|
+
last_seen: number | null;
|
|
1257
|
+
online: boolean;
|
|
1258
|
+
};
|
|
1259
|
+
export type AuditEntry = { at: number; who: string; method: string; path: string; status: number; ms: number };
|
|
1260
|
+
|
|
1187
1261
|
/* --- the admin panel (GET /api/admin) ------------------------------------------------------------ */
|
|
1188
1262
|
export type OmniAdminSettings = {
|
|
1189
1263
|
enabled: boolean;
|
package/studio/studio.ts
CHANGED
|
@@ -35,7 +35,9 @@ import { resumeAfterApproval, watchDeadline } from "./api/services/approval";
|
|
|
35
35
|
import { startRunner } from "./runner/runner";
|
|
36
36
|
import { cloudSettings, env, writeEnv } from "../tools/platforms.mjs";
|
|
37
37
|
import { checkKey, finishPairing, startPairing } from "../tools/lib/pairing.mjs";
|
|
38
|
-
import { ACCESS_PREFIX, accessToken } from "../tools/lib/oauth-client.mjs";
|
|
38
|
+
import { ACCESS_PREFIX, accessToken, login, readTokens } from "../tools/lib/oauth-client.mjs";
|
|
39
|
+
import { createSelfUpdater } from "../tools/lib/self-update.mjs";
|
|
40
|
+
import { setupRunning } from "../tools/lib/setup-progress.mjs";
|
|
39
41
|
import { openUrl } from "../tools/lib/platform.mjs";
|
|
40
42
|
|
|
41
43
|
const args = process.argv.slice(2);
|
|
@@ -146,6 +148,29 @@ if (args.includes("--runner") || (hosted.on && option("port") === undefined)) {
|
|
|
146
148
|
};
|
|
147
149
|
/* only a Phase 1 key is repaired by pairing; an OAuth login refreshes itself or asks for omni login */
|
|
148
150
|
const check = async (t: string) => (t.startsWith(ACCESS_PREFIX) ? { valid: null } : checkKey(url, t));
|
|
149
|
-
|
|
151
|
+
/* no usable login (never entered, signed out, disconnected): the approval page opens on this computer by itself */
|
|
152
|
+
const signIn = async () => {
|
|
153
|
+
/* the plugin's hook may be asking for this very login: wait for it instead of opening a second page */
|
|
154
|
+
for (let i = 0; i < 144 && setupRunning(); i++) await new Promise(r => setTimeout(r, 5000));
|
|
155
|
+
if (readTokens(url)) return;
|
|
156
|
+
await login(url, { deviceName: hostname().replace(/\.local$/, "") });
|
|
157
|
+
};
|
|
158
|
+
/* a machine behind the hosted studio updates itself: the hub closes the socket with UPGRADE, and this asks npm
|
|
159
|
+
whether a newer CLI exists (tools/lib/self-update.mjs); never on a checkout, once an hour */
|
|
160
|
+
const update = createSelfUpdater({
|
|
161
|
+
log: line => console.log(line),
|
|
162
|
+
busy: setupRunning,
|
|
163
|
+
loggedIn: () => !!readTokens(url),
|
|
164
|
+
});
|
|
165
|
+
startRunner({
|
|
166
|
+
url,
|
|
167
|
+
token,
|
|
168
|
+
port,
|
|
169
|
+
handle,
|
|
170
|
+
checkKey: check,
|
|
171
|
+
pair,
|
|
172
|
+
login: signIn,
|
|
173
|
+
onOutdated: async () => void (await update()),
|
|
174
|
+
});
|
|
150
175
|
}
|
|
151
176
|
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/* The root route component: the top banners, the menu, the page (<Outlet/>) inside the scrolling
|
|
2
|
-
.content, the lightbox, the approval notice, Omni
|
|
2
|
+
.content, the lightbox, the approval notice, Omni (the voice agent, its panel opens from the sidebar button) and
|
|
3
|
+
the toasts. While the studio isn't connected, its screen takes the page's place inside .content, with the menu
|
|
4
|
+
still there; the page stays mounted underneath, hidden, so what it held is back when the studio is. Settings →
|
|
5
|
+
Agente de IA stays reachable: the computers and the actions there come from the Worker, not from the studio. */
|
|
3
6
|
|
|
4
7
|
import { useEffect, useRef } from "react";
|
|
5
8
|
import { Outlet, useLocation, useMatchRoute } from "@tanstack/react-router";
|
|
6
9
|
import { useMenuCollapsed } from "../hooks/useMenuCollapsed";
|
|
7
10
|
import { useMissingSetupSteps } from "../hooks/useMissingSetupSteps";
|
|
11
|
+
import { useRunner } from "../hooks/useRunner";
|
|
8
12
|
import { ApprovalNotice } from "./ApprovalNotice";
|
|
9
13
|
import { TopBanners } from "./TopBanners";
|
|
10
14
|
import { Lightbox } from "./Lightbox";
|
|
@@ -32,6 +36,9 @@ export function AppShell() {
|
|
|
32
36
|
const [collapsed, toggleMenu] = useMenuCollapsed();
|
|
33
37
|
const showGettingStarted = location.pathname === "/getting-started" || !!missing?.length;
|
|
34
38
|
const withSidebar = !!matchRoute({ to: "/settings", fuzzy: true }) || !!matchRoute({ to: "/usage" });
|
|
39
|
+
const { online } = useRunner();
|
|
40
|
+
const onAgentSettings = !!matchRoute({ to: "/settings/$category", params: { category: "agent" } });
|
|
41
|
+
const offlineScreen = online === false && !onAgentSettings;
|
|
35
42
|
|
|
36
43
|
/* the content panel scrolls, not the window: back to the top on every page change */
|
|
37
44
|
const pageKey = pageKeyOf(location.pathname, location.searchStr);
|
|
@@ -51,15 +58,16 @@ export function AppShell() {
|
|
|
51
58
|
showGettingStarted={showGettingStarted}
|
|
52
59
|
/>
|
|
53
60
|
<div className="content" ref={content}>
|
|
54
|
-
<main className={withSidebar ? "with-sidebar" : ""}>
|
|
61
|
+
<main className={withSidebar ? "with-sidebar" : ""} hidden={offlineScreen}>
|
|
55
62
|
<Outlet />
|
|
56
63
|
</main>
|
|
64
|
+
{/* always mounted: it is what sends the first time on to Primeiros passos once the studio answers */}
|
|
65
|
+
<StudioOffline suppressed={onAgentSettings} />
|
|
57
66
|
</div>
|
|
58
67
|
</div>
|
|
59
68
|
<Lightbox />
|
|
60
69
|
<ApprovalNotice />
|
|
61
70
|
<OmniWidget menuCollapsed={collapsed} />
|
|
62
|
-
<StudioOffline />
|
|
63
71
|
<Toaster />
|
|
64
72
|
</>
|
|
65
73
|
);
|