@crievo/omni 0.1.7 → 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.
Files changed (68) hide show
  1. package/omnivideo.config.example.json +1 -0
  2. package/package.json +1 -1
  3. package/reference/cloud.md +15 -1
  4. package/reference/learnings.md +7 -0
  5. package/reference/studio.md +1 -0
  6. package/studio/api/app.ts +4 -0
  7. package/studio/api/routes/cost.ts +13 -0
  8. package/studio/api/routes/me.ts +8 -0
  9. package/studio/api/services/agent-tools.ts +2 -2
  10. package/studio/api/services/cost.ts +22 -0
  11. package/studio/api/services/me.ts +67 -0
  12. package/studio/api/services/sessions.ts +8 -2
  13. package/studio/runner/runner.ts +57 -10
  14. package/studio/shared/omni-screens.ts +1 -1
  15. package/studio/shared/schemas/cost.ts +10 -0
  16. package/studio/shared/types/api.ts +74 -0
  17. package/studio/studio.ts +27 -2
  18. package/studio/web/components/AppShell.tsx +11 -3
  19. package/studio/web/components/FirstConnection.tsx +222 -0
  20. package/studio/web/components/OfflineNotice.tsx +59 -0
  21. package/studio/web/components/SetupSteps.tsx +63 -0
  22. package/studio/web/components/Sidebar.tsx +10 -25
  23. package/studio/web/components/StudioOffline.tsx +48 -50
  24. package/studio/web/components/UserCard.tsx +79 -0
  25. package/studio/web/components/icons.tsx +2 -0
  26. package/studio/web/hooks/useComputers.ts +55 -0
  27. package/studio/web/hooks/useCost.ts +11 -0
  28. package/studio/web/hooks/useMe.ts +32 -0
  29. package/studio/web/hooks/useSetupProgress.ts +54 -0
  30. package/studio/web/lib/me.ts +17 -0
  31. package/studio/web/lib/query-client.ts +6 -0
  32. package/studio/web/lib/settings-categories.ts +2 -2
  33. package/studio/web/lib/setup-retry.ts +29 -0
  34. package/studio/web/lib/studio-offline.ts +105 -59
  35. package/studio/web/pages/ConnectPreviewPage/index.tsx +215 -0
  36. package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +18 -9
  37. package/studio/web/pages/SettingsPage/components/AgentCostPanel.tsx +253 -0
  38. package/studio/web/pages/SettingsPage/components/ComputersPanel.tsx +145 -0
  39. package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +2 -2
  40. package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +3 -3
  41. package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +7 -2
  42. package/studio/web/pages/SettingsPage/index.tsx +15 -1
  43. package/studio/web/pages/VideoPage/components/PublishForm.tsx +5 -2
  44. package/studio/web/router.tsx +8 -0
  45. package/studio/web/styles/components/setup-steps.css +163 -0
  46. package/studio/web/styles/components/user-card.css +121 -0
  47. package/studio/web/styles/index.css +2 -0
  48. package/studio/web/styles/pages/settings/shell.css +79 -0
  49. package/studio/web/styles/pages/settings/usage.css +47 -0
  50. package/studio/web/styles/pages/studio-offline.css +224 -38
  51. package/tools/cost.mjs +114 -0
  52. package/tools/lib/agents.mjs +3 -2
  53. package/tools/lib/channel.mjs +1 -1
  54. package/tools/lib/cloud-user.mjs +58 -0
  55. package/tools/lib/cost.mjs +357 -0
  56. package/tools/lib/deps.mjs +27 -12
  57. package/tools/lib/oauth-client.mjs +28 -14
  58. package/tools/lib/pages-style.mjs +67 -0
  59. package/tools/lib/pairing.mjs +1 -1
  60. package/tools/lib/schedule/schedule.mjs +1 -1
  61. package/tools/lib/self-update.mjs +122 -0
  62. package/tools/lib/setup-progress.mjs +315 -0
  63. package/tools/lib/up.mjs +176 -61
  64. package/tools/metrics.mjs +6 -1
  65. package/tools/omni.mjs +41 -16
  66. package/tools/omnichat.mjs +45 -9
  67. package/tools/platforms.mjs +75 -14
  68. package/tools/runner.mjs +6 -6
@@ -293,6 +293,7 @@
293
293
  "omnichat": false,
294
294
  "metrics": false,
295
295
  "schedule": false,
296
+ "user": false,
296
297
  "studio": false,
297
298
  "studio_url": ""
298
299
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crievo/omni",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "OmniVideo no seu computador: o CLI omni, o studio local e as ferramentas do pipeline.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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>/runner/audit`) and limited to 120 a minute per user (429 beyond it); a runner and a hub on
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
@@ -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
+
@@ -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 (o Mac estava desligado)",
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 ? "este Mac" : "ninguém (agente parado)",
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
- const costs = r.turns.map(x => turnInfo(r, x).result?.cost ?? 0);
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: +costs.reduce((a, b) => a + b, 0).toFixed(4),
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
  }
@@ -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: este Mac não está pareado com o studio hospedado: npx -y @crievo/omni login");
136
+ if (!hinted) log("aviso: esta máquina não está conectada ao studio hospedado");
118
137
  hinted = true;
119
- timer = setTimeout(connect, KEY_CHECK_MS);
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: este Mac foi desconectado do studio hospedado; para voltar: node tools/runner.mjs login");
194
+ log("aviso: esta máquina foi desconectada do studio hospedado");
175
195
  hinted = true;
176
- timer = setTimeout(connect, KEY_CHECK_MS);
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) log(`aviso: este Mac e o studio hospedado não se entendem (${e.reason}); tento de novo em 1 min`);
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
- timer = setTimeout(connect, 60_000);
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, KEY_CHECK_MS);
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 deste Mac; abri a página de aprovação no navegador: aprove para reconectar",
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: Mac pareado de novo");
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, KEY_CHECK_MS);
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 das vozes", path: "/usage" },
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
  };
@@ -0,0 +1,10 @@
1
+ /* Agent cost (settings Uso e custo): ?days=N limits the window, none = everything recorded. */
2
+
3
+ import { z } from "zod";
4
+
5
+ export const costQuery = z.object({
6
+ days: z
7
+ .string()
8
+ .regex(/^\d{1,4}$/)
9
+ .optional(),
10
+ });
@@ -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
- startRunner({ url, token, port, handle, checkKey: check, pair });
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, the toasts and, over everything, the screen for a studio that lost its connection, the voice agent (its panel opens from the sidebar button). */
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
  );