lua-cli 3.45.0 → 3.47.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/docs/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # lua-cli v3.43.0
1
+ # lua-cli v3.47.0
2
2
 
3
- Welcome to the comprehensive API documentation for lua-cli v3.43.0. This guide covers every API, class, and function exported by the package.
3
+ Welcome to the comprehensive API documentation for lua-cli v3.47.0. This guide covers every API, class, and function exported by the package.
4
4
 
5
5
  ## 📚 Documentation Index
6
6
 
@@ -263,8 +263,8 @@ Each API doc includes:
263
263
 
264
264
  ---
265
265
 
266
- **Version:** 3.29.1
267
- **Last Updated:** August 2026
266
+ **Version:** 3.47.0
267
+ **Last Updated:** October 2026
268
268
 
269
269
  ---
270
270
 
@@ -81,6 +81,8 @@ await Agents.workflows('base_agent-tp').deploy('wf_123', '1.4.0', { expectActive
81
81
  await Agents.versions('base_agent-tp').delete(5);
82
82
  ```
83
83
 
84
+ > **Experimental.** `Agents.jobs(agentId).register({ versionId, jobId })` registers job `jobId`, defined in the staged version `versionId`, on the target. It is available in a deployed agent only once the platform serves `jobs.register`, and needs the `supervision:register-job` grant (given per supervisor and target by an org owner or admin, scoped to jobs defined in the staged `versionId`). Without it the call is refused with `supervision_not_granted`. Under `lua test` / `lua dev` it is always refused with `supervision_release_offline_refused`, with no network call. An empty `versionId` or `jobId` is refused with `supervision_bad_args`, and an empty agent id with `supervision_no_target`.
85
+
84
86
  - A workflow deploy also records a new active agent version. Read `versions.list` again before a later `promote`.
85
87
  - `commitHash` on `workflows.deploy` is optional and forwarded unchanged. A call without it behaves as before, and the platform may fill it from the Job workspace.
86
88
 
@@ -115,7 +117,7 @@ await Agents.versions('base_agent-tp').delete(5);
115
117
  ### Offline (`lua test`, `lua dev`)
116
118
 
117
119
  - **Read members** call the developer API with **your** credential and mirror the projections above: env keys only, and logs without user ids or message bodies.
118
- - **Refused offline:** `versions.promote`, `persona.activate`, `versions.delete`, `workflows.create`, `workflows.push`, `workflows.deploy`, `versions.preview` and `data`. A local test never changes a live agent, and readable collections live on the grant.
120
+ - **Refused offline:** `versions.promote`, `persona.activate`, `versions.delete`, `workflows.create`, `workflows.push`, `workflows.deploy`, `jobs.register` (experimental), `versions.preview` and `data`. A local test never changes a live agent, and readable collections live on the grant.
119
121
  - **Not available offline:** `Agents.list`, `Agents.get`, `source.get` and `Agents.deployed` (`supervision_offline_unsupported`).
120
122
 
121
123
  ### Errors
@@ -123,6 +125,9 @@ await Agents.versions('base_agent-tp').delete(5);
123
125
  Every error is a `SupervisionError` with a `code`:
124
126
 
125
127
  - `supervision_not_granted`
128
+ - `supervision_release_offline_refused`, for a release op (or the experimental `jobs.register`) run offline
129
+ - `supervision_bad_args`, a local check of the experimental `jobs.register`: an empty `versionId` or `jobId`
130
+ - `supervision_no_target`, a local check: no agent id is given
126
131
  - `supervision_not_found`, for another org or an unknown agent
127
132
  - `supervision_self_refused`
128
133
  - `supervision_release_binding_required` and `supervision_release_binding_refused`
package/docs/api/User.md CHANGED
@@ -266,7 +266,7 @@ await user.send([
266
266
 
267
267
  #### `user.clear()`
268
268
 
269
- Clears all user data.
269
+ Clears all user data, including the user's My work list.
270
270
 
271
271
  **Returns:** `Promise<{ success: boolean }>` - `{ success: true }` once the data is cleared. Throws if the clear fails.
272
272
 
@@ -277,6 +277,57 @@ const user = await User.get();
277
277
  await user.clear();
278
278
  ```
279
279
 
280
+ #### `user.setWorkItems(items)`
281
+
282
+ Publishes this user's **My work** list: the open work an agent lists for them (a ticket they raised or hold, an approval waiting on them). Their client shows it beside the conversation, for example Frontline's "My work" tab. Each call replaces the whole list.
283
+
284
+ **Parameters:** `items: WorkItem[]`, at most 100.
285
+
286
+ | Field | Type | Notes |
287
+ | ------------- | ---------------- | ----------------------------------------------------------------------- |
288
+ | `id` | `string` | Unique in the list, ≤ 200 chars (e.g. a ticket number) |
289
+ | `title` | `string` | One line the user recognises, ≤ 120 chars |
290
+ | `group` | `string` | Display group you choose, ≤ 40 chars ("Waiting on you", "Your tickets") |
291
+ | `needsAction` | `boolean?` | The user's own action is needed; listed first and highlighted |
292
+ | `status` | `string?` | Short label, ≤ 40 chars ("In progress") |
293
+ | `updatedAt` | `string \| Date` | When this work last changed |
294
+ | `action` | `string` | Message the client sends when the item is tapped, ≤ 200 chars |
295
+
296
+ **Returns:** `Promise<void>`. Throws an error naming the first invalid item before anything is written, or `Failed to set work items` if the write fails.
297
+
298
+ **Rules:**
299
+
300
+ - **`action` is a show/open request, never a decision.** Use "Show MNT-00123", not "Approve MNT-00123". A tap must not act on a list that may be minutes old; ask for decisions in the conversation instead.
301
+ - **Publish whenever the work changes, for everyone it affects.** That means the raiser, the assignee and the next approver. Use `User.get({ phone })` or `User.get(userId)` to address each of them.
302
+ - **Add a scheduled job (every ~30 min)** that rebuilds the lists of recently active users. It catches any write that failed.
303
+ - **Leave closed or cancelled work out.** An empty list (`[]`) means "nothing waiting".
304
+ - **The list is kept out of `user.data`.** `update()`, `save()` and `patch()` never write it, so a stale read can't overwrite a newer list. `user.clear()` removes it.
305
+
306
+ **Example:**
307
+
308
+ ```typescript
309
+ import { User } from 'lua-cli';
310
+
311
+ // After assigning MNT-00123 to a technician:
312
+ const technician = await User.get({ phone: '+254700000000' });
313
+ await technician?.setWorkItems([
314
+ {
315
+ id: 'MNT-00123',
316
+ title: 'MNT-00123 · Fryer not heating',
317
+ group: 'Assigned to you',
318
+ status: 'In progress',
319
+ updatedAt: new Date(),
320
+ action: 'Show MNT-00123',
321
+ },
322
+ ]);
323
+ ```
324
+
325
+ #### `user.clearWorkItems()`
326
+
327
+ Removes this user's My work list. Their client then shows no list from this agent.
328
+
329
+ **Returns:** `Promise<void>`
330
+
280
331
  #### `user.save()`
281
332
 
282
333
  Saves current user data state.
@@ -14,6 +14,8 @@ _Source of truth: workflows-spec 05 §5.17 (D19-r1/r2). This page is the develop
14
14
 
15
15
  **Suspension does not release the workspace.** When your run waits for a person or a signal, its volume is left exactly as it was — it keeps holding one of the org's `maxWorkspacesPerOrg` slots until a terminal reason releases it, or, for a kept `empty` workspace, `ttlHours` expires (168 h at most once the ceiling is enabled). Releasing a workspace at suspension is not implemented yet (tracked as SA-134); until then, commit before a step suspends so your work is durable on the run branch `lua/wf-<lineageId>` independent of the volume.
16
16
 
17
+ **Storage is metered.** A Job-tier workspace volume, and every worktree-arm clone volume, is metered as GiB-hours (`sizeGb` × hours held, summed over the clones) on a `workflow:storage` usage record written when the volume is deleted; a held volume counts until its TTL expiry. The record is attributed to your organization and is for visibility only: it does not charge credits or touch the wallet, and whether storage is billed is a separate platform decision.
18
+
17
19
  **Worktree arms:** parallel arms never share a filesystem; each gets its own clone and branch and the `merge` step integrates them — put `verify` on the workspace so an agent-resolved conflict must pass your tests. `onConflict:'agent'` lets one resolver turn fix conflicts.
18
20
 
19
21
  ## Commands from a code step — `ctx.exec` / `ctx.$`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lua-cli",
3
- "version": "3.45.0",
3
+ "version": "3.47.0",
4
4
  "description": "Build, test, and deploy AI agents with custom tools, webhooks, and scheduled jobs. Features LuaAgent unified configuration, streaming chat, and batch deployment.",
5
5
  "readmeFilename": "README.md",
6
6
  "main": "dist/api-exports.js",
@@ -116,9 +116,9 @@
116
116
  "tsup": "^8.5.1",
117
117
  "@lua/sandbox-runtime": "0.0.1",
118
118
  "@lua/shared-sandbox": "0.0.1",
119
+ "@lua/workflow-graph": "0.0.1",
119
120
  "@lua/shared-source-sync": "0.0.1",
120
- "@lua/shared-types": "0.0.1",
121
- "@lua/workflow-graph": "0.0.1"
121
+ "@lua/shared-types": "0.0.1"
122
122
  },
123
123
  "scripts": {
124
124
  "clean": "rm -rf dist temp",
@@ -20,7 +20,7 @@
20
20
  "inquirer": "^12.9.6",
21
21
  "stripe": "^17.5.0",
22
22
  "js-yaml": "^4.1.0",
23
- "lua-cli": "^3.45.0",
23
+ "lua-cli": "^3.47.0",
24
24
  "openai": "^5.23.0",
25
25
  "uuid": "^13.0.0",
26
26
  "zod": "^3.24.1"
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@base-ui/react": "~1.8.0",
11
+ "@lua-ai-global/app-client": "~0.0.2",
11
12
  "@lua-ai-global/ui": "~0.2.0",
12
13
  "@phosphor-icons/react": "~2.1.10",
13
14
  "react": "~19.3.0",
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { Button } from '@lua-ai-global/ui/button';
3
3
  import { Card } from '@lua-ai-global/ui/card';
4
- import { lua } from './lua/client';
4
+ import { lua } from '@lua-ai-global/app-client';
5
5
 
6
6
  export function App() {
7
7
  const [message, setMessage] = useState<string>();
@@ -1,7 +1,7 @@
1
1
  import { StrictMode } from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
3
  import { App } from './App';
4
- import { lua } from './lua/client';
4
+ import { lua } from '@lua-ai-global/app-client';
5
5
  import './index.css';
6
6
 
7
7
  // Theme follows the shell: first from the bootstrap, then from the host's `init`.
@@ -1,269 +0,0 @@
1
- /**
2
- * Lua app client — the page side of a Lua web app.
3
- *
4
- * - Session: the shell opens this app in an iframe with a single-use handoff
5
- * code in the URL fragment (`#code=…`). The client exchanges it with lua-auth
6
- * for a Firebase custom token, signs in (identitytoolkit) and keeps the ID
7
- * token in memory only, refreshing it (securetoken) before it expires. No
8
- * cookie, no localStorage: a reload asks the shell for a new code.
9
- * - Bridge (protocol v1): posts `lua:ready` once loaded, takes `lua:init`
10
- * (theme, density, locale, path) from the host, posts `lua:auth:expired`
11
- * when the session cannot be renewed so the shell re-issues a code. Messages
12
- * go only to, and are only accepted from, the shells the gateway lists in
13
- * the bootstrap (`hostOrigins`, the same list as the CSP's frame-ancestors).
14
- * - `api(method, path, body?)`: calls a route of this app through the gateway
15
- * (`/_lua/api/<path>`) with the session. A 401 renews the token once and
16
- * retries; only a second 401 ends the session.
17
- *
18
- * Under `lua apps dev` the bootstrap says `dev: true`: there is no handoff and
19
- * the dev server runs routes as the developer.
20
- *
21
- * Generated by `lua apps new`; yours to edit. (A packaged `@lua-ai-global/app-client`
22
- * replaces this file later.)
23
- */
24
-
25
- export interface LuaBootstrap {
26
- v: 1;
27
- app: { id: string; name: string; version: string; deployment: number; origin: string };
28
- apiBase: string;
29
- manifest: string;
30
- auth: { origin: string; exchangePath: string; audience: string; firebaseApiKey?: string };
31
- embedded: boolean;
32
- /** The shells allowed to frame this app; the bridge talks to these only. */
33
- hostOrigins?: string[];
34
- theme: 'light' | 'dark';
35
- density: 'compact' | 'comfortable';
36
- preview: boolean;
37
- /** Set by `lua apps dev` only. */
38
- dev?: boolean;
39
- }
40
-
41
- export interface LuaInit {
42
- v: 1;
43
- theme?: 'light' | 'dark';
44
- density?: 'compact' | 'comfortable';
45
- locale?: string;
46
- path?: string;
47
- }
48
-
49
- declare global {
50
- interface Window {
51
- __LUA__?: LuaBootstrap;
52
- }
53
- }
54
-
55
- const BRIDGE_VERSION = 1;
56
- const IDENTITY = 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken';
57
- const SECURE_TOKEN = 'https://securetoken.googleapis.com/v1/token';
58
- /** Renew this long before the ID token expires. */
59
- const REFRESH_MARGIN_MS = 5 * 60_000;
60
-
61
- export class LuaApiError extends Error {
62
- constructor(
63
- readonly status: number,
64
- readonly code: string | undefined,
65
- message: string,
66
- readonly executionId?: string
67
- ) {
68
- super(message);
69
- this.name = 'LuaApiError';
70
- }
71
- }
72
-
73
- interface Tokens {
74
- idToken: string;
75
- refreshToken: string;
76
- expiresInS: number;
77
- }
78
-
79
- const nonEmpty = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
80
-
81
- /** A token response is only a session when every field is there; anything else is "no session". */
82
- function tokensFrom(idToken: unknown, refreshToken: unknown, expiresIn: unknown): Tokens | null {
83
- const expiresInS = Number(expiresIn);
84
- if (!nonEmpty(idToken) || !nonEmpty(refreshToken) || !Number.isFinite(expiresInS) || expiresInS <= 0) return null;
85
- return { idToken, refreshToken, expiresInS };
86
- }
87
-
88
- async function jsonOrNull(response: Response | undefined): Promise<Record<string, unknown> | null> {
89
- if (!response?.ok) return null;
90
- try {
91
- const body = await response.json();
92
- return body && typeof body === 'object' ? (body as Record<string, unknown>) : null;
93
- } catch {
94
- return null;
95
- }
96
- }
97
-
98
- class LuaAppClient {
99
- private readonly boot: LuaBootstrap | undefined = window.__LUA__;
100
- private idToken: string | undefined;
101
- private refreshToken: string | undefined;
102
- private refreshTimer: ReturnType<typeof setTimeout> | undefined;
103
- private renewing: Promise<boolean> | undefined;
104
- private session: Promise<void> | undefined;
105
- private readonly initListeners: Array<(init: LuaInit) => void> = [];
106
-
107
- get appName(): string {
108
- return this.boot?.app.name ?? document.title;
109
- }
110
-
111
- get theme(): 'light' | 'dark' {
112
- return this.boot?.theme ?? 'dark';
113
- }
114
-
115
- onInit(listener: (init: LuaInit) => void): void {
116
- this.initListeners.push(listener);
117
- }
118
-
119
- /** Take the handoff code, open the session, tell the host we are ready. */
120
- start(): Promise<void> {
121
- if (!this.session) {
122
- window.addEventListener('message', (event) => {
123
- // Only the shell that framed us, from an origin the gateway allows, speaks the bridge.
124
- if (event.source !== window.parent || !this.hostOrigins().includes(event.origin)) return;
125
- if (event.data?.type !== 'lua:init') return;
126
- for (const listener of this.initListeners) listener(event.data as LuaInit);
127
- });
128
- this.session = this.openSession().finally(() => this.post({ type: 'lua:ready', v: BRIDGE_VERSION }));
129
- }
130
- return this.session;
131
- }
132
-
133
- async api<T>(method: string, path: string, body?: unknown): Promise<T> {
134
- await this.start();
135
- let response = await this.send(method, path, body);
136
- // A 401 usually means the ID token lapsed between refreshes: renew once and retry once.
137
- if (response.status === 401 && (await this.renew())) response = await this.send(method, path, body);
138
- if (response.status === 401) this.expired();
139
- const text = await response.text();
140
- let data: any;
141
- try {
142
- data = text ? JSON.parse(text) : undefined;
143
- } catch {
144
- data = text;
145
- }
146
- if (!response.ok) {
147
- throw new LuaApiError(
148
- response.status,
149
- data?.code,
150
- data?.message ?? response.statusText,
151
- response.headers.get('x-lua-execution-id') ?? undefined
152
- );
153
- }
154
- return data as T;
155
- }
156
-
157
- private send(method: string, path: string, body?: unknown): Promise<Response> {
158
- const base = this.boot?.apiBase ?? '/_lua/api';
159
- const headers: Record<string, string> = { accept: 'application/json' };
160
- if (this.idToken) headers.authorization = `Bearer ${this.idToken}`;
161
- if (body !== undefined) headers['content-type'] = 'application/json';
162
- return fetch(`${base}${path.startsWith('/') ? path : `/${path}`}`, {
163
- method,
164
- headers,
165
- body: body === undefined ? undefined : JSON.stringify(body),
166
- });
167
- }
168
-
169
- private async openSession(): Promise<void> {
170
- const boot = this.boot;
171
- if (!boot || boot.dev) return; // `lua apps dev`: the dev server is the caller's session.
172
- const code = new URLSearchParams(location.hash.slice(1)).get('code');
173
- // The code is single-use: drop it from the URL before anything can copy it.
174
- if (code) history.replaceState(null, '', location.pathname + location.search);
175
- if (!code || !boot.auth.firebaseApiKey) return this.expired();
176
-
177
- const exchanged = await jsonOrNull(
178
- await fetch(`${boot.auth.origin}${boot.auth.exchangePath}`, {
179
- method: 'POST',
180
- headers: { 'content-type': 'application/json' },
181
- body: JSON.stringify({ code, audience: boot.auth.audience }),
182
- }).catch(() => undefined)
183
- );
184
- if (!nonEmpty(exchanged?.token)) return this.expired();
185
-
186
- const signedIn = await jsonOrNull(
187
- await fetch(`${IDENTITY}?key=${encodeURIComponent(boot.auth.firebaseApiKey)}`, {
188
- method: 'POST',
189
- headers: { 'content-type': 'application/json' },
190
- body: JSON.stringify({ token: exchanged!.token, returnSecureToken: true }),
191
- }).catch(() => undefined)
192
- );
193
- const tokens = signedIn && tokensFrom(signedIn.idToken, signedIn.refreshToken, signedIn.expiresIn);
194
- if (!tokens) return this.expired();
195
- this.keep(tokens);
196
- }
197
-
198
- private keep(tokens: Tokens): void {
199
- this.idToken = tokens.idToken;
200
- this.refreshToken = tokens.refreshToken;
201
- clearTimeout(this.refreshTimer);
202
- this.refreshTimer = setTimeout(
203
- () => void this.renew().then((ok) => ok || this.expired()),
204
- Math.max(tokens.expiresInS * 1000 - REFRESH_MARGIN_MS, 30_000)
205
- );
206
- }
207
-
208
- /** Swap the refresh token for a new ID token. One renewal at a time; `false` when it cannot be done. */
209
- private renew(): Promise<boolean> {
210
- if (!this.renewing) {
211
- this.renewing = this.refreshOnce().finally(() => (this.renewing = undefined));
212
- }
213
- return this.renewing;
214
- }
215
-
216
- private async refreshOnce(): Promise<boolean> {
217
- const key = this.boot?.auth.firebaseApiKey;
218
- if (!key || !this.refreshToken) return false;
219
- const r = await jsonOrNull(
220
- await fetch(`${SECURE_TOKEN}?key=${encodeURIComponent(key)}`, {
221
- method: 'POST',
222
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
223
- body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: this.refreshToken }),
224
- }).catch(() => undefined)
225
- );
226
- const tokens = r && tokensFrom(r.id_token, r.refresh_token, r.expires_in);
227
- if (!tokens) return false;
228
- this.keep(tokens);
229
- return true;
230
- }
231
-
232
- /** The session is gone: the shell answers with a fresh handoff code. */
233
- private expired(): void {
234
- this.idToken = undefined;
235
- this.refreshToken = undefined;
236
- clearTimeout(this.refreshTimer);
237
- this.post({ type: 'lua:auth:expired', v: BRIDGE_VERSION });
238
- }
239
-
240
- private hostOrigins(): string[] {
241
- return this.boot?.hostOrigins ?? [];
242
- }
243
-
244
- /**
245
- * One post per allowed shell origin: the browser delivers it only when the
246
- * framing window really has that origin, so no other page ever receives it.
247
- *
248
- * A shell on a custom scheme (lua-desktop's `tauri://localhost`) is not a
249
- * valid postMessage target origin: WebKit throws "Invalid target origin",
250
- * which aborted the loop and the session ("Could not load" in lua-desktop),
251
- * and skipping it would leave the desktop shell without `lua:ready`.
252
- * Such an origin is posted with '*': the messages carry only {type, v}, and
253
- * the CSP's frame-ancestors already limits who can frame the app. Each post
254
- * is isolated so one origin can never stop the others.
255
- */
256
- private post(message: { type: string; v: number }): void {
257
- if (window.parent === window) return;
258
- const targets = new Set(this.hostOrigins().map((origin) => (/^https?:\/\//i.test(origin) ? origin : '*')));
259
- for (const target of targets) {
260
- try {
261
- window.parent.postMessage(message, target);
262
- } catch {
263
- // An origin this browser will not target: skip it, keep the rest.
264
- }
265
- }
266
- }
267
- }
268
-
269
- export const lua = new LuaAppClient();