startup-builder 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/chunk-ZR74VEI2.js +2322 -0
- package/dist/cli.js +338 -188
- package/dist/index.d.ts +660 -430
- package/dist/index.js +77 -17
- package/package.json +23 -16
- package/dist/chunk-NWM7TYW6.js +0 -1004
|
@@ -0,0 +1,2322 @@
|
|
|
1
|
+
// src/next/api.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
4
|
+
function originOf(u) {
|
|
5
|
+
try {
|
|
6
|
+
const o = new URL(u).origin;
|
|
7
|
+
return o === "null" ? void 0 : o;
|
|
8
|
+
} catch {
|
|
9
|
+
return void 0;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function checkBase(base) {
|
|
13
|
+
let u;
|
|
14
|
+
try {
|
|
15
|
+
u = new URL(base);
|
|
16
|
+
} catch {
|
|
17
|
+
throw new Error(`refusing the API base "${base}": not a URL`);
|
|
18
|
+
}
|
|
19
|
+
if (u.protocol === "https:" || u.protocol === "http:" && LOOPBACK.has(u.hostname)) return u;
|
|
20
|
+
throw new Error(`refusing the API base ${u.origin || base}: https only (http only on 127.0.0.1, localhost or [::1])`);
|
|
21
|
+
}
|
|
22
|
+
var Api = class {
|
|
23
|
+
base;
|
|
24
|
+
token;
|
|
25
|
+
/** the base’s origin: the only one the bearer is sent to */
|
|
26
|
+
origin;
|
|
27
|
+
/** every URL this client read, in order (a worklist lists them as its sources) */
|
|
28
|
+
reads = [];
|
|
29
|
+
f;
|
|
30
|
+
ua;
|
|
31
|
+
constructor(o) {
|
|
32
|
+
this.base = o.base.replace(/\/+$/, "");
|
|
33
|
+
this.origin = checkBase(this.base).origin;
|
|
34
|
+
this.token = o.token && (o.bind === void 0 || originOf(o.bind) === this.origin) ? o.token : void 0;
|
|
35
|
+
this.f = o.fetch ?? fetch;
|
|
36
|
+
this.ua = o.userAgent ?? "startup-builder";
|
|
37
|
+
}
|
|
38
|
+
/** A path (`/barcoding.dev/actions`) or a full URL (an `$id`), as a URL on this API. */
|
|
39
|
+
url(pathOrUrl) {
|
|
40
|
+
if (/^https?:\/\//.test(pathOrUrl)) return pathOrUrl;
|
|
41
|
+
return this.base + (pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`);
|
|
42
|
+
}
|
|
43
|
+
/** is this URL on the base’s origin (scheme, host and port)? */
|
|
44
|
+
sameOrigin(url) {
|
|
45
|
+
try {
|
|
46
|
+
return new URL(url).origin === this.origin;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
headers(url, extra = {}) {
|
|
52
|
+
return {
|
|
53
|
+
accept: "application/json",
|
|
54
|
+
"user-agent": this.ua,
|
|
55
|
+
...this.token && this.sameOrigin(url) ? { authorization: `Bearer ${this.token}` } : {},
|
|
56
|
+
...extra
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async get(pathOrUrl, accept) {
|
|
60
|
+
const url = this.url(pathOrUrl);
|
|
61
|
+
this.reads.push(url);
|
|
62
|
+
const r = await this.f(url, { headers: this.headers(url, accept ? { accept } : {}) });
|
|
63
|
+
return wrap(r, url);
|
|
64
|
+
}
|
|
65
|
+
/** POST a Verb (`<$id>:<verb>`). Idempotency-Key: a retry with the same key never runs it twice. api.sb wants one on
|
|
66
|
+
* every POST not in try Mode; the CLI sends one always (a fresh UUID unless the caller reuses one for a retry). */
|
|
67
|
+
async post(pathOrUrl, body, o = {}) {
|
|
68
|
+
const url = this.url(pathOrUrl);
|
|
69
|
+
if (!this.sameOrigin(url)) {
|
|
70
|
+
let origin = url;
|
|
71
|
+
try {
|
|
72
|
+
origin = new URL(url).origin;
|
|
73
|
+
} catch {
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`refusing to POST to ${origin}: not ${this.origin}, the API this CLI points at (--api or SB_API). A Verb goes to that API only`);
|
|
76
|
+
}
|
|
77
|
+
const idempotencyKey = o.idempotencyKey || randomUUID();
|
|
78
|
+
const r = await this.f(url, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: this.headers(url, { "content-type": "application/json", "idempotency-key": idempotencyKey, ...o.prefer ? { prefer: o.prefer } : {} }),
|
|
81
|
+
body: JSON.stringify(body ?? {})
|
|
82
|
+
});
|
|
83
|
+
return { ...await wrap(r, url), idempotencyKey };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
async function wrap(r, url) {
|
|
87
|
+
const text = await r.text();
|
|
88
|
+
let json;
|
|
89
|
+
try {
|
|
90
|
+
json = JSON.parse(text);
|
|
91
|
+
} catch {
|
|
92
|
+
json = void 0;
|
|
93
|
+
}
|
|
94
|
+
const status = typeof json?.error?.status === "number" && r.status === 200 ? json.error.status : r.status;
|
|
95
|
+
const headers = {};
|
|
96
|
+
r.headers.forEach((v, k) => headers[k.toLowerCase()] = v);
|
|
97
|
+
return { status, ok: status >= 200 && status < 300, url, json, text, headers };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/next/config.ts
|
|
101
|
+
import { homedir } from "os";
|
|
102
|
+
import { join } from "path";
|
|
103
|
+
var DEFAULT_API = "https://api.sb";
|
|
104
|
+
var ISSUER = "https://id.org.ai";
|
|
105
|
+
var DEFAULT_RUNNER = "https://claude-runner.workers.do";
|
|
106
|
+
var LOOPBACK_PORTS = [8976, 8977, 8978];
|
|
107
|
+
var SCOPE = "openid profile email offline_access";
|
|
108
|
+
var API_VARS = ["SB_API", "STARTUP_BUILDER_API", "API_SB_URL"];
|
|
109
|
+
function batchApi(flag, env = process.env) {
|
|
110
|
+
const batch = env.SB_BATCH;
|
|
111
|
+
if (!batch) return void 0;
|
|
112
|
+
if (flag !== void 0) throw new Error("--api is refused inside a batch (SB_BATCH is set): the loop sets the API");
|
|
113
|
+
const set = API_VARS.map((k) => [k, env[k]]).filter(([, v]) => v);
|
|
114
|
+
const own = originOf(batch);
|
|
115
|
+
if (!own) {
|
|
116
|
+
const origins = new Set(set.map(([, v]) => originOf(v) ?? v));
|
|
117
|
+
if (origins.size > 1) throw new Error(`refusing ${set.map(([k]) => k).join(", ")}: they name different APIs inside a batch (SB_BATCH is set)`);
|
|
118
|
+
return (set[0]?.[1] ?? DEFAULT_API).replace(/\/+$/, "");
|
|
119
|
+
}
|
|
120
|
+
for (const [k, v] of set) if (originOf(v) !== own) throw new Error(`refusing ${k}=${originOf(v) ?? v}: inside a batch (SB_BATCH is set) the API is the batch's own, ${own}`);
|
|
121
|
+
return own;
|
|
122
|
+
}
|
|
123
|
+
function apiBase(flag, env = process.env) {
|
|
124
|
+
const b = batchApi(flag, env);
|
|
125
|
+
if (b) return b;
|
|
126
|
+
return (flag || env.SB_API || env.STARTUP_BUILDER_API || env.API_SB_URL || DEFAULT_API).replace(/\/+$/, "");
|
|
127
|
+
}
|
|
128
|
+
function tokenFile(env = process.env) {
|
|
129
|
+
return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "api.sb", "token.json");
|
|
130
|
+
}
|
|
131
|
+
function runnerBase(flag, env = process.env) {
|
|
132
|
+
return (flag || env.SB_RUNNER_URL || DEFAULT_RUNNER).replace(/\/+$/, "");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/next/auth.ts
|
|
136
|
+
import { createServer } from "http";
|
|
137
|
+
import { spawn } from "child_process";
|
|
138
|
+
import { createHash, randomBytes } from "crypto";
|
|
139
|
+
import { mkdir, readFile, writeFile, rm, chmod } from "fs/promises";
|
|
140
|
+
import { dirname } from "path";
|
|
141
|
+
import { platform } from "os";
|
|
142
|
+
var b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
143
|
+
function pkce(verifier = b64url(randomBytes(32))) {
|
|
144
|
+
return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()) };
|
|
145
|
+
}
|
|
146
|
+
function unwrapState(s) {
|
|
147
|
+
if (!s) return s;
|
|
148
|
+
try {
|
|
149
|
+
const o = JSON.parse(Buffer.from(s, "base64url").toString("utf8"));
|
|
150
|
+
if (typeof o?.csrf === "string" && typeof o.s === "string") return o.s;
|
|
151
|
+
} catch {
|
|
152
|
+
}
|
|
153
|
+
return s;
|
|
154
|
+
}
|
|
155
|
+
function openDefaultBrowser(url) {
|
|
156
|
+
const os = platform();
|
|
157
|
+
if (os === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
|
|
158
|
+
const [cmd, args] = os === "darwin" ? ["open", [url]] : os === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
159
|
+
try {
|
|
160
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
161
|
+
return true;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
var Auth = class {
|
|
167
|
+
api;
|
|
168
|
+
file;
|
|
169
|
+
issuer;
|
|
170
|
+
f;
|
|
171
|
+
open;
|
|
172
|
+
log;
|
|
173
|
+
now;
|
|
174
|
+
ports;
|
|
175
|
+
timeoutMs;
|
|
176
|
+
fixedClient;
|
|
177
|
+
constructor(d) {
|
|
178
|
+
this.api = d.api;
|
|
179
|
+
this.file = d.file;
|
|
180
|
+
this.issuer = d.issuer ?? ISSUER;
|
|
181
|
+
this.f = d.fetch ?? fetch;
|
|
182
|
+
this.open = d.openBrowser ?? openDefaultBrowser;
|
|
183
|
+
this.log = d.log ?? ((l) => process.stderr.write(l + "\n"));
|
|
184
|
+
this.now = d.now ?? Date.now;
|
|
185
|
+
this.ports = d.ports ?? LOOPBACK_PORTS;
|
|
186
|
+
this.timeoutMs = d.timeoutMs ?? 3e5;
|
|
187
|
+
this.fixedClient = d.clientId ?? process.env.API_SB_CLIENT_ID;
|
|
188
|
+
}
|
|
189
|
+
async stored() {
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(await readFile(this.file, "utf8"));
|
|
192
|
+
} catch {
|
|
193
|
+
return void 0;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** `api`: the API this sign-in is for (a refresh keeps the one it was issued for, never the current --api) */
|
|
197
|
+
async save(client, j, api = this.api) {
|
|
198
|
+
const s = {
|
|
199
|
+
issuer: this.issuer,
|
|
200
|
+
client_id: client,
|
|
201
|
+
access_token: j.access_token,
|
|
202
|
+
refresh_token: j.refresh_token,
|
|
203
|
+
expires_at: j.expires_in ? this.now() + j.expires_in * 1e3 : void 0,
|
|
204
|
+
api
|
|
205
|
+
};
|
|
206
|
+
await mkdir(dirname(this.file), { recursive: true, mode: 448 });
|
|
207
|
+
await writeFile(this.file, JSON.stringify(s, null, 2), { mode: 384 });
|
|
208
|
+
await chmod(this.file, 384);
|
|
209
|
+
return s;
|
|
210
|
+
}
|
|
211
|
+
/** api.sb's public CLI client, named at /.well-known/agents.json (auth.cli_client_id) */
|
|
212
|
+
async clientId() {
|
|
213
|
+
if (this.fixedClient) return this.fixedClient;
|
|
214
|
+
const r = await this.f(`${this.api}/.well-known/agents.json`, { headers: { accept: "application/json" } });
|
|
215
|
+
const id = (await r.json().catch(() => ({}))).auth?.cli_client_id;
|
|
216
|
+
if (!id) throw new Error(`${this.api}/.well-known/agents.json names no auth.cli_client_id; set API_SB_CLIENT_ID`);
|
|
217
|
+
return id;
|
|
218
|
+
}
|
|
219
|
+
async token(params) {
|
|
220
|
+
const r = await this.f(`${this.issuer}/oauth/token`, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
223
|
+
body: new URLSearchParams(params)
|
|
224
|
+
});
|
|
225
|
+
const j = await r.json().catch(() => ({}));
|
|
226
|
+
return { ok: r.ok, j };
|
|
227
|
+
}
|
|
228
|
+
/** loopback + PKCE: a one-shot listener on 127.0.0.1, the browser opened at id.org.ai, the code caught.
|
|
229
|
+
* Undefined when no browser can open here (the caller falls back to the device flow). */
|
|
230
|
+
async loopback(client) {
|
|
231
|
+
const { verifier, challenge } = pkce();
|
|
232
|
+
const state = b64url(randomBytes(16));
|
|
233
|
+
for (const port of this.ports) {
|
|
234
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
235
|
+
const got = await new Promise((resolve2) => {
|
|
236
|
+
let timer;
|
|
237
|
+
const server = createServer((req, res) => {
|
|
238
|
+
const u = new URL(req.url ?? "/", redirectUri);
|
|
239
|
+
if (u.pathname !== "/callback") {
|
|
240
|
+
res.writeHead(404).end();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const ok = unwrapState(u.searchParams.get("state")) === state && u.searchParams.get("code");
|
|
244
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", connection: "close" }).end(
|
|
245
|
+
ok ? '<!doctype html><title>startup-builder</title><body style="font:16px system-ui;margin:3em">Signed in to api.sb. You can close this tab.</body>' : '<!doctype html><title>startup-builder</title><body style="font:16px system-ui;margin:3em">Sign-in failed. Return to the terminal.</body>'
|
|
246
|
+
);
|
|
247
|
+
if (timer) clearTimeout(timer);
|
|
248
|
+
server.close();
|
|
249
|
+
server.closeAllConnections?.();
|
|
250
|
+
resolve2(ok ? { code: u.searchParams.get("code") } : { error: u.searchParams.get("error") ?? "state mismatch" });
|
|
251
|
+
});
|
|
252
|
+
server.once("error", () => resolve2("busy"));
|
|
253
|
+
server.listen(port, "127.0.0.1", () => {
|
|
254
|
+
const url = new URL(`${this.issuer}/oauth/authorize`);
|
|
255
|
+
const q = { response_type: "code", client_id: client, redirect_uri: redirectUri, scope: SCOPE, state, code_challenge: challenge, code_challenge_method: "S256" };
|
|
256
|
+
for (const [k, v] of Object.entries(q)) url.searchParams.set(k, v);
|
|
257
|
+
if (!this.open(url.toString())) {
|
|
258
|
+
server.close();
|
|
259
|
+
resolve2({ error: "no-browser" });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
this.log("Opened your browser to sign in with id.org.ai. Waiting\u2026");
|
|
263
|
+
timer = setTimeout(() => {
|
|
264
|
+
server.close();
|
|
265
|
+
resolve2({ error: `timed out after ${Math.round(this.timeoutMs / 1e3)}s` });
|
|
266
|
+
}, this.timeoutMs);
|
|
267
|
+
timer.unref();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
if (got === "busy") continue;
|
|
271
|
+
if (got.error === "no-browser") return void 0;
|
|
272
|
+
if (got.error) throw new Error(`sign-in failed: ${got.error}`);
|
|
273
|
+
const t2 = await this.token({ grant_type: "authorization_code", code: got.code, redirect_uri: redirectUri, code_verifier: verifier, client_id: client });
|
|
274
|
+
if (!t2.ok) throw new Error(`id.org.ai refused the code: ${t2.j.error ?? ""} ${t2.j.error_description ?? ""}`.trim());
|
|
275
|
+
return t2.j;
|
|
276
|
+
}
|
|
277
|
+
throw new Error(`ports ${this.ports.join(", ")} are all in use`);
|
|
278
|
+
}
|
|
279
|
+
/** the fallback where no browser can open here: the device-code flow */
|
|
280
|
+
async device(client) {
|
|
281
|
+
const r = await this.f(`${this.issuer}/oauth/device`, {
|
|
282
|
+
method: "POST",
|
|
283
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
284
|
+
body: new URLSearchParams({ client_id: client, scope: SCOPE })
|
|
285
|
+
});
|
|
286
|
+
const d = await r.json();
|
|
287
|
+
if (!d.device_code) throw new Error(`device authorization failed: ${d.error ?? r.status}`);
|
|
288
|
+
this.log(`On any device, open ${d.verification_uri_complete ?? d.verification_uri}${d.verification_uri_complete ? "" : ` and enter ${d.user_code}`}`);
|
|
289
|
+
let wait = (d.interval ?? 5) * 1e3;
|
|
290
|
+
const until = this.now() + (d.expires_in ?? 600) * 1e3;
|
|
291
|
+
while (this.now() < until) {
|
|
292
|
+
await new Promise((res) => setTimeout(res, wait));
|
|
293
|
+
const t2 = await this.token({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: d.device_code, client_id: client });
|
|
294
|
+
if (t2.ok) return t2.j;
|
|
295
|
+
if (t2.j.error === "slow_down") wait += 5e3;
|
|
296
|
+
else if (t2.j.error !== "authorization_pending") throw new Error(`device sign-in failed: ${t2.j.error}`);
|
|
297
|
+
}
|
|
298
|
+
throw new Error("device sign-in expired");
|
|
299
|
+
}
|
|
300
|
+
/** The stored token when still valid, else a refreshed one; undefined when neither (never opens a browser).
|
|
301
|
+
* What every API call uses: an absent or dead token means the call goes anonymous. */
|
|
302
|
+
async current() {
|
|
303
|
+
const s = await this.stored();
|
|
304
|
+
if (!s) return void 0;
|
|
305
|
+
if (!s.expires_at || s.expires_at > this.now() + 3e4) return s;
|
|
306
|
+
if (s.refresh_token) {
|
|
307
|
+
const t2 = await this.token({ grant_type: "refresh_token", refresh_token: s.refresh_token, client_id: s.client_id });
|
|
308
|
+
if (t2.ok) return this.save(s.client_id, t2.j, s.api || DEFAULT_API);
|
|
309
|
+
}
|
|
310
|
+
return void 0;
|
|
311
|
+
}
|
|
312
|
+
/** Sign in: the stored or refreshed token, else the browser (loopback), else the device flow. */
|
|
313
|
+
async login(opts = {}) {
|
|
314
|
+
if (opts.force) await rm(this.file, { force: true });
|
|
315
|
+
const have = await this.current();
|
|
316
|
+
if (have) return { stored: have, fresh: false };
|
|
317
|
+
const client = await this.clientId();
|
|
318
|
+
const j = !opts.device && await this.loopback(client) || await this.device(client);
|
|
319
|
+
return { stored: await this.save(client, j), fresh: true };
|
|
320
|
+
}
|
|
321
|
+
async logout() {
|
|
322
|
+
const s = await this.stored();
|
|
323
|
+
if (s?.refresh_token) {
|
|
324
|
+
await this.f(`${this.issuer}/oauth/revoke`, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
327
|
+
body: new URLSearchParams({ token: s.refresh_token, client_id: s.client_id })
|
|
328
|
+
}).catch(() => void 0);
|
|
329
|
+
}
|
|
330
|
+
await rm(this.file, { force: true });
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// src/next/worklist.ts
|
|
335
|
+
var WorklistError = class extends Error {
|
|
336
|
+
constructor(message, status, body) {
|
|
337
|
+
super(message);
|
|
338
|
+
this.status = status;
|
|
339
|
+
this.body = body;
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
function worklistPath(worker, startups, limit, md = false, page) {
|
|
343
|
+
const q = [...startups.length ? [`startup=${startups.map(encodeURIComponent).join(",")}`] : [], `limit=${limit}`, ...page && page > 1 ? [`page=${page}`] : [], ...md ? ["md"] : []];
|
|
344
|
+
return `/workers/${encodeURIComponent(worker)}/worklist?${q.join("&")}`;
|
|
345
|
+
}
|
|
346
|
+
var heldByNames = (h) => Array.isArray(h) ? h : h && typeof h === "object" ? Object.keys(h) : [];
|
|
347
|
+
function apiError(r, what) {
|
|
348
|
+
const e = r.json?.error;
|
|
349
|
+
const msg = typeof e === "string" ? e : e?.message ?? (r.text && !r.json ? r.text.slice(0, 300) : `HTTP ${r.status}`);
|
|
350
|
+
const code = typeof e?.code === "string" ? ` (${e.code})` : "";
|
|
351
|
+
const workers = Array.isArray(r.json?.workers) ? r.json.workers.map((u) => u.replace(/\/worklist$/, "").split("/").pop()) : [];
|
|
352
|
+
return `${what} answered ${r.status}: ${msg}${code}${workers.length ? ` (Workers: ${workers.join(", ")})` : ""}`;
|
|
353
|
+
}
|
|
354
|
+
async function fetchWorklistMarkdown(api, o) {
|
|
355
|
+
const worker = parseWorker(o.worker);
|
|
356
|
+
if (!worker) throw new Error("name a Worker: worklist for:<worker>");
|
|
357
|
+
const path = worklistPath(worker, o.startups, o.limit ?? 5, true, o.page);
|
|
358
|
+
const r = await api.get(path, "text/markdown");
|
|
359
|
+
if (!r.ok) throw new WorklistError(apiError(r, `GET ${path}`), r.status, r.json ?? r.text);
|
|
360
|
+
return r.text;
|
|
361
|
+
}
|
|
362
|
+
function parseWorker(arg) {
|
|
363
|
+
if (!arg) return void 0;
|
|
364
|
+
const w = arg.replace(/^for:/i, "").trim().toLowerCase().replace(/\s+/g, "-");
|
|
365
|
+
return w || void 0;
|
|
366
|
+
}
|
|
367
|
+
var dayMs = 864e5;
|
|
368
|
+
var daysBetween = (from, to) => Math.round((Date.parse(to.slice(0, 10)) - Date.parse(from.slice(0, 10))) / dayMs);
|
|
369
|
+
function derivePriority(t2, today) {
|
|
370
|
+
if (typeof t2.priority === "number") return { priority: t2.priority, why: [`${t2.priority} from api.sb`] };
|
|
371
|
+
const why = [];
|
|
372
|
+
let p = 50;
|
|
373
|
+
const overdue = Number(t2.overdue ?? 0);
|
|
374
|
+
const dueIn = t2.due ? daysBetween(today, String(t2.due)) : void 0;
|
|
375
|
+
if (overdue > 0) {
|
|
376
|
+
p = 80 + Math.min(overdue, 10);
|
|
377
|
+
why.push(`overdue ${overdue}d: ${p}`);
|
|
378
|
+
} else if (dueIn !== void 0 && dueIn <= 2) {
|
|
379
|
+
p = 70 + (2 - Math.max(dueIn, 0)) * 3;
|
|
380
|
+
why.push(`due in ${Math.max(dueIn, 0)}d: ${p}`);
|
|
381
|
+
} else if ((t2.$type ?? t2.type) === "Issue") {
|
|
382
|
+
p = 60;
|
|
383
|
+
why.push("an open Issue: 60");
|
|
384
|
+
} else {
|
|
385
|
+
why.push("Normal: 50");
|
|
386
|
+
}
|
|
387
|
+
const weeks = Math.min(Math.floor(Number(t2.age ?? 0) / 7), 9);
|
|
388
|
+
if (weeks > 0) {
|
|
389
|
+
p += weeks;
|
|
390
|
+
why.push(`+${weeks} for ${t2.age}d waiting`);
|
|
391
|
+
}
|
|
392
|
+
return { priority: Math.min(p, overdue > 0 ? 99 : 89), why };
|
|
393
|
+
}
|
|
394
|
+
function belowStartup(url, domain) {
|
|
395
|
+
if (!url) return void 0;
|
|
396
|
+
let path;
|
|
397
|
+
try {
|
|
398
|
+
path = new URL(url).pathname;
|
|
399
|
+
} catch {
|
|
400
|
+
path = url;
|
|
401
|
+
}
|
|
402
|
+
const segs = path.split("/").filter(Boolean);
|
|
403
|
+
const at = segs.lastIndexOf(domain.split("/").pop());
|
|
404
|
+
return at >= 0 ? segs.slice(at + 1).join("/") : segs.join("/");
|
|
405
|
+
}
|
|
406
|
+
function stepOf(t2, domain) {
|
|
407
|
+
if (typeof t2.step === "string" && t2.step) return t2.step;
|
|
408
|
+
const obj = belowStartup(t2.object, domain);
|
|
409
|
+
if (obj) return obj;
|
|
410
|
+
return t2.verb ? `${t2.verb}` : "other";
|
|
411
|
+
}
|
|
412
|
+
var PAST = { send: "sent", win: "won", lose: "lost", buy: "bought", sell: "sold", pay: "paid", hold: "held", rerun: "reran" };
|
|
413
|
+
var DOUBLE = /* @__PURE__ */ new Set(["stop", "plan", "win", "ship", "drop"]);
|
|
414
|
+
function eventForm(verb) {
|
|
415
|
+
const w = verb;
|
|
416
|
+
if (PAST[w]) return PAST[w];
|
|
417
|
+
if (/e$/.test(w)) return `${w}d`;
|
|
418
|
+
if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}ied`;
|
|
419
|
+
return `${DOUBLE.has(w) ? w + w.at(-1) : w}ed`;
|
|
420
|
+
}
|
|
421
|
+
function byPriority(a, b) {
|
|
422
|
+
return b.priority - a.priority || (b.overdue ?? 0) - (a.overdue ?? 0) || (b.age ?? 0) - (a.age ?? 0) || a.name.localeCompare(b.name);
|
|
423
|
+
}
|
|
424
|
+
function selectBatch(tasks, limit) {
|
|
425
|
+
const top = [...tasks].sort(byPriority).slice(0, Math.max(limit, 0));
|
|
426
|
+
const clusters = /* @__PURE__ */ new Map();
|
|
427
|
+
for (const t2 of top) {
|
|
428
|
+
const key = `${t2.worker}\0${t2.startup}\0${t2.step}`;
|
|
429
|
+
let c = clusters.get(key);
|
|
430
|
+
if (!c) clusters.set(key, c = { worker: t2.worker, startup: t2.startup, step: t2.step, tasks: [] });
|
|
431
|
+
c.tasks.push(t2);
|
|
432
|
+
}
|
|
433
|
+
const order = (k) => {
|
|
434
|
+
const seen = [];
|
|
435
|
+
for (const c of clusters.values()) if (!seen.includes(k(c))) seen.push(k(c));
|
|
436
|
+
return (c) => seen.indexOf(k(c));
|
|
437
|
+
};
|
|
438
|
+
const w = order((c) => c.worker);
|
|
439
|
+
const s = order((c) => `${c.worker}/${c.startup}`);
|
|
440
|
+
return [...clusters.values()].sort((a, b) => w(a) - w(b) || s(a) - s(b));
|
|
441
|
+
}
|
|
442
|
+
function groupBatch(batch) {
|
|
443
|
+
const groups = [];
|
|
444
|
+
for (const c of batch) {
|
|
445
|
+
let g = groups.find((x) => x.startup === c.startup);
|
|
446
|
+
if (!g) groups.push(g = { startup: c.startup, steps: [] });
|
|
447
|
+
let s = g.steps.find((x) => x.step === c.step);
|
|
448
|
+
if (!s) g.steps.push(s = { step: c.step, ...c.stepTitle ? { stepTitle: c.stepTitle } : {}, tasks: [] });
|
|
449
|
+
s.tasks.push(...c.tasks);
|
|
450
|
+
}
|
|
451
|
+
for (const g of groups) for (const s of g.steps) s.tasks.sort(byPriority);
|
|
452
|
+
return groups;
|
|
453
|
+
}
|
|
454
|
+
var today0 = () => (/* @__PURE__ */ new Date()).toLocaleDateString("en-CA");
|
|
455
|
+
var keysOf = (o) => o && typeof o === "object" && !Array.isArray(o) ? Object.keys(o) : [];
|
|
456
|
+
async function openTasks(api, domain, worker) {
|
|
457
|
+
const out = [];
|
|
458
|
+
let next = `/${domain}/actions?assignee=${encodeURIComponent(worker)}&status=Pending&limit=50`;
|
|
459
|
+
for (let page = 0; next && page < 10; page++) {
|
|
460
|
+
const r = await api.get(next);
|
|
461
|
+
if (!r.ok) break;
|
|
462
|
+
for (const a of r.json?.actions ?? r.json?.tasks ?? []) out.push(a);
|
|
463
|
+
next = r.json?.links?.next ?? void 0;
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
async function roleDesk(api, url, name) {
|
|
468
|
+
const [desk, perms] = await Promise.all([api.get(url), api.get(`${url}/permissions?limit=50`)]);
|
|
469
|
+
const d = desk.json ?? {};
|
|
470
|
+
return {
|
|
471
|
+
name: d.role?.name ?? name,
|
|
472
|
+
$id: d.role?.$id ?? url,
|
|
473
|
+
owns: d.role?.owns,
|
|
474
|
+
summary: Array.isArray(d.summary) ? d.summary.map(String) : [],
|
|
475
|
+
permissions: (perms.json?.permissions ?? []).map((p) => String(p.name)),
|
|
476
|
+
limits: Object.entries(d.limits ?? {}).map(([n, l]) => ({ name: n, limit: l?.limit, used: l?.used, text: l?.usedShareText ? `${l.usedText ?? l.used} of ${l.limitText ?? l.limit} used (${l.usedShareText})` : void 0 })),
|
|
477
|
+
tools: keysOf(d.tools)
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function guessClosesOn(t2, tools) {
|
|
481
|
+
if (!t2.verb) return void 0;
|
|
482
|
+
const tool = tools.find((x) => x.endsWith(`.${t2.verb}`));
|
|
483
|
+
if (tool) {
|
|
484
|
+
const noun = tool.slice(0, tool.lastIndexOf("."));
|
|
485
|
+
return { event: `${noun}.${eventForm(t2.verb)}`, verb: tool, url: `${t2.$id}:${t2.verb}`, via: "derived" };
|
|
486
|
+
}
|
|
487
|
+
return { event: `${t2.type}.${eventForm(t2.verb)}`, verb: `${t2.type}.${t2.verb}`, url: `${t2.$id}:${t2.verb}`, target: t2.$id, via: "derived" };
|
|
488
|
+
}
|
|
489
|
+
async function preview(api, t2, roleNames, tools) {
|
|
490
|
+
if (!t2.verb) return void 0;
|
|
491
|
+
const url = `${t2.$id}:${t2.verb}`;
|
|
492
|
+
const r = await api.get(url);
|
|
493
|
+
const j = r.json ?? {};
|
|
494
|
+
if (r.ok && j.verb?.event) {
|
|
495
|
+
const heldBy = j.checks?.permission?.heldBy ?? {};
|
|
496
|
+
return {
|
|
497
|
+
event: String(j.verb.event),
|
|
498
|
+
verb: String(j.verb.name ?? `${t2.type}.${t2.verb}`),
|
|
499
|
+
url,
|
|
500
|
+
target: t2.$id,
|
|
501
|
+
via: "preview",
|
|
502
|
+
...j.checks?.permission?.needs ? { permission: { needs: String(j.checks.permission.needs), heldBy, held: Object.keys(heldBy).some((h) => roleNames.includes(h)) } } : {},
|
|
503
|
+
...typeof j.checks?.approval?.needed === "boolean" ? { approvalNeeded: j.checks.approval.needed } : {},
|
|
504
|
+
...j.action?.status === "Refused" ? { refused: String(j.action.reason ?? "refused") } : {}
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
return guessClosesOn(t2, tools);
|
|
508
|
+
}
|
|
509
|
+
async function buildWorklist(api, o) {
|
|
510
|
+
const worker = parseWorker(o.worker);
|
|
511
|
+
if (!worker) throw new Error("name a Worker: worklist for:<worker>");
|
|
512
|
+
const limit = o.limit ?? 5;
|
|
513
|
+
const today = o.today ?? today0();
|
|
514
|
+
const gaps = [];
|
|
515
|
+
if (!o.stubOnly) {
|
|
516
|
+
const path = worklistPath(worker, o.startups, limit, false, o.page);
|
|
517
|
+
let live;
|
|
518
|
+
try {
|
|
519
|
+
live = await api.get(path);
|
|
520
|
+
} catch (e) {
|
|
521
|
+
gaps.push(`OFFLINE FALLBACK: api.sb unreachable (${e?.message ?? e}); the CLI's stub assembled this, api.sb did not serve it`);
|
|
522
|
+
}
|
|
523
|
+
if (live) {
|
|
524
|
+
if (live.ok && live.json?.worklist) {
|
|
525
|
+
const first = served(live);
|
|
526
|
+
return o.all ? followAll(api, first) : first;
|
|
527
|
+
}
|
|
528
|
+
if (live.status < 500) throw new WorklistError(apiError(live, `GET ${path}`), live.status, live.json ?? live.text);
|
|
529
|
+
gaps.push(`OFFLINE FALLBACK: ${apiError(live, `GET ${path}`)}; the CLI's stub assembled this, api.sb did not serve it`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (!o.startups.length) throw new Error("the offline stub needs --startup <domain>");
|
|
533
|
+
return stubWorklist(api, worker, o, limit, today, gaps).catch((e) => {
|
|
534
|
+
throw new Error(`api.sb is unreachable and the offline stub reads api.sb too: ${e?.message ?? e}`);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
function served(r) {
|
|
538
|
+
const w = r.json.worklist;
|
|
539
|
+
const l = r.json.links ?? {};
|
|
540
|
+
const links = Object.fromEntries(["next", "prev", "first", "last", "self"].filter((k) => typeof l[k] === "string").map((k) => [k, l[k]]));
|
|
541
|
+
return { ...w, source: w.source ?? "api.sb", ...Object.keys(links).length && !w.links ? { links } : {} };
|
|
542
|
+
}
|
|
543
|
+
async function followAll(api, first) {
|
|
544
|
+
const pages = [first];
|
|
545
|
+
const seen = /* @__PURE__ */ new Set();
|
|
546
|
+
let next = first.next ?? first.links?.next;
|
|
547
|
+
while (next && !seen.has(next) && pages.length < 100) {
|
|
548
|
+
seen.add(next);
|
|
549
|
+
const r = await api.get(next);
|
|
550
|
+
if (!r.ok || !r.json?.worklist) throw new WorklistError(apiError(r, `GET ${next}`), r.status, r.json ?? r.text);
|
|
551
|
+
const w = served(r);
|
|
552
|
+
pages.push(w);
|
|
553
|
+
next = w.next ?? w.links?.next;
|
|
554
|
+
}
|
|
555
|
+
if (pages.length === 1) return first;
|
|
556
|
+
const last2 = pages[pages.length - 1];
|
|
557
|
+
const tasks = pages.flatMap((p) => p.tasks);
|
|
558
|
+
return {
|
|
559
|
+
...first,
|
|
560
|
+
batch: pages.flatMap((p) => p.batch),
|
|
561
|
+
tasks,
|
|
562
|
+
page: first.page ?? 1,
|
|
563
|
+
pages: last2.pages ?? pages.length,
|
|
564
|
+
more: last2.more ?? 0,
|
|
565
|
+
// the CLI's words, not api.sb's: the served moreText describes one page
|
|
566
|
+
moreText: `${tasks.length} of ${first.open} open Tasks, ${pages.length} pages (--all followed next)${last2.more ? `; ${last2.more} more` : ""}`,
|
|
567
|
+
next: last2.next,
|
|
568
|
+
links: { ...first.links ?? {}, next: last2.links?.next },
|
|
569
|
+
sources: [...new Set(pages.flatMap((p) => p.sources ?? []))]
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
async function stubWorklist(api, worker, o, limit, today, gaps) {
|
|
573
|
+
const desks = [];
|
|
574
|
+
const all = [];
|
|
575
|
+
const rolesOf = /* @__PURE__ */ new Map();
|
|
576
|
+
const toolsOf = /* @__PURE__ */ new Map();
|
|
577
|
+
let who = { handle: worker, name: worker };
|
|
578
|
+
for (const domain of o.startups) {
|
|
579
|
+
const [w, raw, ctx] = await Promise.all([
|
|
580
|
+
api.get(`/${domain}/workers/${encodeURIComponent(worker)}`),
|
|
581
|
+
openTasks(api, domain, worker),
|
|
582
|
+
api.get(`/${domain}/context?md`, "text/markdown")
|
|
583
|
+
]);
|
|
584
|
+
const wk = w.ok ? w.json?.worker : void 0;
|
|
585
|
+
if (!wk) gaps.push(`${domain}: no Worker "${worker}" (${w.status})`);
|
|
586
|
+
else who = { handle: wk.handle ?? worker, name: wk.name ?? worker, kind: wk.kind, model: wk.model };
|
|
587
|
+
const fills = wk?.fills ? Object.entries(wk.fills) : (wk?.roles ?? []).map((u) => [u.split("/").pop(), u]);
|
|
588
|
+
const roles = await Promise.all(fills.map(([n, u]) => roleDesk(api, u, n)));
|
|
589
|
+
rolesOf.set(domain, roles.map((r) => r.name));
|
|
590
|
+
toolsOf.set(domain, roles.flatMap((r) => r.tools));
|
|
591
|
+
const glossaryOk = ctx.ok && !ctx.text.trimStart().startsWith("{");
|
|
592
|
+
if (!glossaryOk) gaps.push(`${domain}: no Startup-scoped glossary at /${domain}/context (${ctx.status})`);
|
|
593
|
+
desks.push({
|
|
594
|
+
domain,
|
|
595
|
+
$id: api.url(`/${domain}`),
|
|
596
|
+
...wk ? { worker: { name: wk.name, kind: wk.kind, model: wk.model, $id: wk.$id, skills: keysOf(wk.equippedWith) } } : {},
|
|
597
|
+
roles,
|
|
598
|
+
open: raw.length,
|
|
599
|
+
...glossaryOk ? { glossary: ctx.text.trim() } : {},
|
|
600
|
+
glossaryUrl: api.url(`/${domain}/context`)
|
|
601
|
+
});
|
|
602
|
+
for (const a of raw) {
|
|
603
|
+
const { priority, why } = derivePriority(a, today);
|
|
604
|
+
all.push({
|
|
605
|
+
$id: a.$id,
|
|
606
|
+
type: a.$type ?? a.type ?? "Task",
|
|
607
|
+
name: a.name,
|
|
608
|
+
status: a.status,
|
|
609
|
+
...a.reason ? { reason: a.reason } : {},
|
|
610
|
+
startup: domain,
|
|
611
|
+
worker,
|
|
612
|
+
...pick(a, ["role", "verb", "object", "about", "conversation", "due", "overdue", "age", "since"]),
|
|
613
|
+
priority,
|
|
614
|
+
priorityWhy: why,
|
|
615
|
+
step: stepOf(a, domain)
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const batch = selectBatch(all, limit);
|
|
620
|
+
const tasks = batch.flatMap((c) => c.tasks);
|
|
621
|
+
if (o.previews !== false) {
|
|
622
|
+
await Promise.all(tasks.map(async (t2) => t2.closesOn = await preview(api, t2, rolesOf.get(t2.startup) ?? [], toolsOf.get(t2.startup) ?? [])));
|
|
623
|
+
}
|
|
624
|
+
if (all.some((t2) => !t2.priorityWhy[0]?.endsWith("from api.sb"))) gaps.push("priority: api.sb serves none on a Task; derived by the CLI (derivePriority)");
|
|
625
|
+
gaps.push("step: api.sb serves none on a Task; taken from the Thing it acts on, else its Verb");
|
|
626
|
+
const derived = tasks.filter((t2) => t2.closesOn?.via === "derived").length;
|
|
627
|
+
if (derived) gaps.push(`closesOn: ${derived} of ${tasks.length} Tasks have no Verb preview; the closing Event is guessed from the Verb`);
|
|
628
|
+
return {
|
|
629
|
+
source: "stub",
|
|
630
|
+
worker: who,
|
|
631
|
+
startups: desks,
|
|
632
|
+
open: all.length,
|
|
633
|
+
limit,
|
|
634
|
+
batch,
|
|
635
|
+
tasks,
|
|
636
|
+
gaps,
|
|
637
|
+
sources: [...new Set(api.reads)],
|
|
638
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function pick(o, keys) {
|
|
642
|
+
const out = {};
|
|
643
|
+
for (const k of keys) if (o[k] !== void 0 && o[k] !== null) out[k] = o[k];
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// src/next/close.ts
|
|
648
|
+
var THING_KEYS = ["task", "issue", "approval", "action"];
|
|
649
|
+
function thingOf(page) {
|
|
650
|
+
if (!page) return void 0;
|
|
651
|
+
for (const k of THING_KEYS) if (page[k]?.$id) return page[k];
|
|
652
|
+
for (const [k, v] of Object.entries(page)) if (!["api", "links", "nav", "user"].includes(k) && v && typeof v === "object" && v.$id && v.$type) return v;
|
|
653
|
+
return void 0;
|
|
654
|
+
}
|
|
655
|
+
var lastSeg = (u) => u ? new URL(u, "https://x").pathname.split("/").filter(Boolean).pop() ?? "" : "";
|
|
656
|
+
var closedText = (c) => typeof c === "string" ? c : c && typeof c === "object" ? Object.entries(c).filter(([, v]) => typeof v !== "object").map(([k, v]) => `${k} ${v}`).join(", ") : "";
|
|
657
|
+
async function findClosesOn(api, taskRef) {
|
|
658
|
+
const g = await api.get(taskRef);
|
|
659
|
+
const thing = thingOf(g.json);
|
|
660
|
+
if (!g.ok || !thing) throw new Error(`GET ${g.url} answered ${g.status}: ${g.json?.error?.message ?? "no Task there"}`);
|
|
661
|
+
const domain = lastSeg(thing.$context ?? thing.for);
|
|
662
|
+
const worker = lastSeg(thing.assignee ?? thing.by);
|
|
663
|
+
if (thing.closedOn !== void 0) {
|
|
664
|
+
throw new Error(`${thing.$id} is closed (${[thing.status, closedText(thing.closedOn), thing.closedBy ? `by ${thing.closedBy}` : ""].filter(Boolean).join(", ")}): nothing to close`);
|
|
665
|
+
}
|
|
666
|
+
if (thing.closesOn) {
|
|
667
|
+
return {
|
|
668
|
+
task: { $id: thing.$id, name: thing.name, type: thing.$type ?? "Task", status: thing.status, startup: domain, worker, ...thing.priorityText ? { priorityText: thing.priorityText } : {}, ...thing.stepTitle ? { stepTitle: thing.stepTitle } : {}, closesOn: thing.closesOn },
|
|
669
|
+
thing
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (!domain || !worker) throw new Error(`${thing.$id} names no Startup ($context) or assignee: api.sb cannot say what closes it`);
|
|
673
|
+
const path = worklistPath(worker, [domain], 50);
|
|
674
|
+
const w = await api.get(path);
|
|
675
|
+
const wl = w.json?.worklist;
|
|
676
|
+
if (!w.ok || !wl) throw new Error(`GET ${path} answered ${w.status}: ${w.json?.error?.message ?? "no worklist"}`);
|
|
677
|
+
const task = wl.tasks.find((t2) => t2.$id === thing.$id);
|
|
678
|
+
if (!task) throw new Error(`${thing.$id} is not on for:${worker}'s worklist in ${domain} (it is ${thing.status}${thing.reason ? `, ${thing.reason}` : ""}): nothing to close`);
|
|
679
|
+
if (!task.closesOn) throw new Error(`api.sb serves no closesOn for ${thing.$id}: it does not say which Event closes it`);
|
|
680
|
+
return { task, thing, worklist: wl };
|
|
681
|
+
}
|
|
682
|
+
function withAs(url, as) {
|
|
683
|
+
if (!as) return url;
|
|
684
|
+
const u = new URL(url);
|
|
685
|
+
u.searchParams.set("as", as);
|
|
686
|
+
return u.toString();
|
|
687
|
+
}
|
|
688
|
+
function outcomeOf(r) {
|
|
689
|
+
if (r.headers["idempotent-replayed"] === "true") return "Replayed";
|
|
690
|
+
if (r.status === 201) return "Done";
|
|
691
|
+
if (r.status === 202) return "Pending";
|
|
692
|
+
if (r.status === 403) return "Refused";
|
|
693
|
+
if (r.status === 422) return "KeyReused";
|
|
694
|
+
if (r.status === 400 || r.status === 401 || r.status === 409) return "Blocked";
|
|
695
|
+
if (r.status === 200) return r.json?.action?.status === "Pending" ? "Pending" : r.json?.action?.status === "Refused" ? "Refused" : "Done";
|
|
696
|
+
return r.ok ? "Done" : "Error";
|
|
697
|
+
}
|
|
698
|
+
async function closeTask(api, o) {
|
|
699
|
+
const mode = o.mode ?? "try";
|
|
700
|
+
const { task } = await findClosesOn(api, o.task);
|
|
701
|
+
const c = task.closesOn;
|
|
702
|
+
const as = o.as ?? task.worker;
|
|
703
|
+
const url = withAs(api.url(c.url), as);
|
|
704
|
+
const base = {
|
|
705
|
+
task: { $id: task.$id, name: task.name, type: task.type, ...task.status ? { status: task.status } : {}, startup: task.startup, worker: task.worker, ...task.priorityText ? { priorityText: task.priorityText } : {}, ...task.stepTitle ? { stepTitle: task.stepTitle } : {} },
|
|
706
|
+
closesOn: c,
|
|
707
|
+
mode,
|
|
708
|
+
as
|
|
709
|
+
};
|
|
710
|
+
const stop = mode !== "try" && !o.force ? c.blocked ?? c.refused : void 0;
|
|
711
|
+
if (stop) {
|
|
712
|
+
return { ...base, request: { method: "POST", url, prefer: `mode=${mode}`, idempotencyKey: o.idempotencyKey ?? "" }, status: 0, outcome: "NotSent", replayed: false, tried: false, note: `${stop} (--force to send anyway; api.sb will refuse it)`, body: void 0 };
|
|
713
|
+
}
|
|
714
|
+
const prep = o.prepare?.(c);
|
|
715
|
+
if (prep?.error) {
|
|
716
|
+
return { ...base, request: { method: "POST", url, prefer: `mode=${mode}`, idempotencyKey: o.idempotencyKey ?? "" }, status: 0, outcome: "NotSent", replayed: false, tried: false, note: prep.error, body: void 0 };
|
|
717
|
+
}
|
|
718
|
+
const r = await api.post(url, prep?.input ?? o.input ?? {}, { idempotencyKey: o.idempotencyKey, prefer: `mode=${mode}` });
|
|
719
|
+
const j = r.json ?? {};
|
|
720
|
+
const outcome = outcomeOf(r);
|
|
721
|
+
const e = j.error && typeof j.error === "object" ? j.error : void 0;
|
|
722
|
+
return {
|
|
723
|
+
...base,
|
|
724
|
+
request: { method: "POST", url, prefer: `mode=${mode}`, idempotencyKey: r.idempotencyKey },
|
|
725
|
+
status: r.status,
|
|
726
|
+
outcome,
|
|
727
|
+
...j.action && typeof j.action === "object" ? { action: j.action } : {},
|
|
728
|
+
replayed: outcome === "Replayed",
|
|
729
|
+
tried: j.action?.stored === false,
|
|
730
|
+
...typeof j.sink === "string" ? { sink: j.sink } : {},
|
|
731
|
+
...e && (e.code || e.message) ? { blocked: { ...e.code ? { code: String(e.code) } : {}, ...e.message ? { message: String(e.message) } : {} } } : {},
|
|
732
|
+
body: r.json ?? r.text.slice(0, 2e3)
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
var closesOf = (a) => (Array.isArray(a?.closes) ? a.closes : []).map((x) => typeof x === "string" ? x : String(x?.$id ?? JSON.stringify(x)));
|
|
736
|
+
|
|
737
|
+
// src/next/render.ts
|
|
738
|
+
import pc from "picocolors";
|
|
739
|
+
var dim = pc.dim;
|
|
740
|
+
function taskBlock(t2, n) {
|
|
741
|
+
const c = t2.closesOn;
|
|
742
|
+
const due = t2.overdue ? ` \xB7 ${t2.overdue}d overdue` : t2.due ? ` \xB7 due ${t2.due}` : "";
|
|
743
|
+
const lines = [` ${n}. ${pc.bold(`[${t2.priorityText ?? t2.priority}]`)} ${t2.name} ${dim(`\xB7 ${t2.type}${due}`)}`, ` ${dim(t2.$id)}`];
|
|
744
|
+
if (c) {
|
|
745
|
+
const p = c.permission;
|
|
746
|
+
const perm = p ? ` \xB7 needs "${p.needs}"${p.held ? p.why ? dim(` (${p.why})`) : "" : pc.yellow(` (held by ${heldByNames(p.heldBy).join(", ") || "no Role"}, not ${t2.worker})`)}` : "";
|
|
747
|
+
lines.push(` closes on ${pc.cyan(c.event)}${c.via === "derived" ? "?" : ""} via ${c.verb}${perm}${c.approvalNeeded ? pc.yellow(" \xB7 needs an Approval") : ""}`);
|
|
748
|
+
if (c.blocked) lines.push(` ${pc.yellow("blocked")}: ${c.blocked}`);
|
|
749
|
+
if (c.refused) lines.push(` ${pc.red("refused")}: ${c.refused}`);
|
|
750
|
+
if (c.says) lines.push(` ${dim(`says: ${c.says}`)}`);
|
|
751
|
+
}
|
|
752
|
+
return lines;
|
|
753
|
+
}
|
|
754
|
+
function batchText(w, o = {}) {
|
|
755
|
+
const bin = o.bin ?? "npx startup-builder";
|
|
756
|
+
const who = `${w.worker.name}${w.worker.kind ? ` (${w.worker.kind}${w.worker.model ? `, ${w.worker.model}` : ""})` : ""}`;
|
|
757
|
+
const src = w.source === "stub" ? pc.yellow("OFFLINE FALLBACK: the CLI's stub assembled this; api.sb did not serve it") : pc.green("api.sb");
|
|
758
|
+
const paged = w.pages && w.pages > 1 && !w.moreText?.includes("--all") ? ` \xB7 page ${w.page ?? 1} of ${w.pages}` : "";
|
|
759
|
+
const lines = [`${pc.bold(`worklist for:${w.worker.handle}`)} \xB7 ${who} \xB7 ${w.tasks.length} of ${w.open} open${paged}${w.asOf ? ` \xB7 as of ${w.asOf.slice(0, 10)}` : ""} \xB7 ${src}`];
|
|
760
|
+
if (w.clock) lines.push(dim(` ${w.clock}`));
|
|
761
|
+
let n = 0;
|
|
762
|
+
for (const g of groupBatch(w.batch)) {
|
|
763
|
+
lines.push("", pc.bold(g.startup));
|
|
764
|
+
for (const s of g.steps) {
|
|
765
|
+
lines.push(` ${pc.cyan(s.stepTitle ?? s.step)}${s.stepTitle ? dim(` (${s.step})`) : ""}`);
|
|
766
|
+
for (const t2 of s.tasks) lines.push(...taskBlock(t2, ++n));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (!w.tasks.length) lines.push("", " Nothing open.");
|
|
770
|
+
if (w.planned?.length) {
|
|
771
|
+
lines.push("", `planned (no Task yet: propose, do not start):`);
|
|
772
|
+
for (const p of w.planned) lines.push(` - ${p.title ?? p.name ?? p.step ?? JSON.stringify(p)}`);
|
|
773
|
+
}
|
|
774
|
+
if (w.moreText) {
|
|
775
|
+
const again = `${bin} worklist for:${w.worker.handle}${w.startups.length ? ` --startup ${w.startups.map((d) => d.domain).join(",")}` : ""}${w.limit !== 5 ? ` --limit ${w.limit}` : ""}`;
|
|
776
|
+
const left = w.more && w.more > 0 ? ` (next: ${again} --page ${(w.page ?? 1) + 1}, or --all)` : "";
|
|
777
|
+
lines.push("", `${w.moreText}${dim(left)}`);
|
|
778
|
+
}
|
|
779
|
+
if (w.limitAsked) lines.push(pc.yellow(`limit ${w.limitAsked} asked; api.sb serves at most ${w.limitMax ?? w.limit} a page`));
|
|
780
|
+
if (w.tasks.length) lines.push("", dim(`close one: ${bin} close <task $id> (a try-Mode preview; --do or --send to run it)`));
|
|
781
|
+
if (w.gaps.length) {
|
|
782
|
+
lines.push(dim(w.source === "stub" ? "gaps (the stub made these up or could not find them):" : "gaps (api.sb says):"));
|
|
783
|
+
for (const gap of w.gaps) lines.push(dim(` - ${gap}`));
|
|
784
|
+
}
|
|
785
|
+
return lines.join("\n");
|
|
786
|
+
}
|
|
787
|
+
function closeText(r) {
|
|
788
|
+
const c = r.closesOn;
|
|
789
|
+
const t2 = r.task;
|
|
790
|
+
const a = r.action;
|
|
791
|
+
const lines = [
|
|
792
|
+
`${pc.bold(t2.name)} ${dim(`\xB7 ${t2.type}${t2.priorityText ? ` \xB7 ${t2.priorityText}` : ""} \xB7 ${t2.startup}${t2.stepTitle ? ` \u203A ${t2.stepTitle}` : ""}`)}`,
|
|
793
|
+
` closes on ${pc.cyan(c.event)} via ${c.verb}${c.target && c.target !== t2.$id ? ` on ${c.target}` : ""}`
|
|
794
|
+
];
|
|
795
|
+
if (c.blocked) lines.push(` ${pc.yellow("blocked")}: ${c.blocked}`);
|
|
796
|
+
if (c.refused) lines.push(` ${pc.red("refused")}: ${c.refused}`);
|
|
797
|
+
lines.push(dim(` POST ${r.request.url} \xB7 Prefer: ${r.request.prefer}${r.request.idempotencyKey ? ` \xB7 Idempotency-Key ${r.request.idempotencyKey}` : ""}`));
|
|
798
|
+
if (r.outcome === "NotSent") {
|
|
799
|
+
lines.push(` ${pc.yellow("not sent")}: ${r.note}`);
|
|
800
|
+
return lines.join("\n");
|
|
801
|
+
}
|
|
802
|
+
const tag = r.outcome === "Refused" ? pc.red("Refused") : r.outcome === "Blocked" || r.outcome === "KeyReused" || r.outcome === "Error" ? pc.red(r.outcome) : r.outcome === "Pending" ? pc.yellow("Pending") : pc.green(r.outcome);
|
|
803
|
+
const status = a?.status && a.status !== r.outcome ? ` (${a.status})` : "";
|
|
804
|
+
lines.push(` \u2192 ${r.status} ${r.tried ? "try: " : ""}${tag}${status}${a?.event ? ` \xB7 ${a.event}` : ""}`);
|
|
805
|
+
if (a?.reason || a?.says) lines.push(` ${a.reason ? `${a.reason}: ` : ""}${a.says ?? ""}`);
|
|
806
|
+
if (r.blocked) lines.push(` ${r.blocked.code ? `${r.blocked.code}: ` : ""}${r.blocked.message ?? ""}`);
|
|
807
|
+
if (r.outcome === "KeyReused" && r.body?.action) lines.push(dim(` the key's Action: ${r.body.action}`));
|
|
808
|
+
const closes = closesOf(a);
|
|
809
|
+
if (closes.length) lines.push(` ${r.tried ? "would close" : "closes"}: ${closes.join(", ")}`);
|
|
810
|
+
if (a?.$id) lines.push(` Action: ${a.$id}`);
|
|
811
|
+
if (Array.isArray(a?.events) && a.events.length) lines.push(dim(` events: ${a.events.map((e) => e?.name ?? e).join(" \u2192 ")}`));
|
|
812
|
+
if (r.sink) lines.push(dim(` sink: ${r.sink}`));
|
|
813
|
+
if (r.tried) lines.push(dim(" (a preview: nothing stored. --do or --send to run it)"));
|
|
814
|
+
return lines.join("\n");
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// src/next/prompt.ts
|
|
818
|
+
var dueText = (t2) => t2.overdue ? `${t2.overdue}d overdue (due ${t2.due})` : t2.due ? `due ${t2.due}` : t2.since ? `since ${t2.since}` : "";
|
|
819
|
+
function closeCommand(t2, bin = "npx startup-builder") {
|
|
820
|
+
return `${bin} close ${t2.$id}`;
|
|
821
|
+
}
|
|
822
|
+
function doCommand(t2, bin = "npx startup-builder") {
|
|
823
|
+
const verb = t2.closesOn?.verb ?? `${t2.type}.${t2.verb ?? "work"}`;
|
|
824
|
+
const noun = verb.slice(0, verb.lastIndexOf("."));
|
|
825
|
+
const target = t2.closesOn ? t2.closesOn.target ?? `<the ${noun}'s $id>` : t2.$id;
|
|
826
|
+
return `${bin} do ${verb} ${target} --startup ${t2.startup}`;
|
|
827
|
+
}
|
|
828
|
+
function taskLines(t2, n, bin) {
|
|
829
|
+
const c = t2.closesOn;
|
|
830
|
+
const lines = [
|
|
831
|
+
`${n}. **${t2.name}** \xB7 ${t2.type} \xB7 priority ${t2.priorityText ?? t2.priority} (${t2.priorityWhy.join("; ")})${dueText(t2) ? ` \xB7 ${dueText(t2)}` : ""}`,
|
|
832
|
+
` - $id: ${t2.$id}`
|
|
833
|
+
];
|
|
834
|
+
if (t2.object) lines.push(` - acts on: ${t2.object}`);
|
|
835
|
+
if (t2.about) lines.push(` - about: ${t2.about}`);
|
|
836
|
+
if (t2.conversation) lines.push(` - conversation: ${t2.conversation}`);
|
|
837
|
+
if (c && c.via === "api.sb") {
|
|
838
|
+
lines.push(` - closes on: **${c.event}**${c.target ? ` on ${c.target}` : ""}${c.says ? ` (${c.says})` : ""}`);
|
|
839
|
+
lines.push(` - close it: \`${closeCommand(t2, bin)}\` previews it (try Mode, nothing stored); add \`--do\` to run it. It POSTs ${c.verb} at ${c.url}`);
|
|
840
|
+
if (c.blocked) lines.push(` - not yet: ${c.blocked}`);
|
|
841
|
+
} else if (c) {
|
|
842
|
+
lines.push(` - closes on: **${c.event}**${c.via === "derived" ? " (guessed from the Verb: api.sb has no preview for it yet)" : ""}`);
|
|
843
|
+
lines.push(` - the Verb: \`${doCommand(t2, bin)}\` (preview first with \`--preview\`${c.via === "preview" ? `; GET ${c.url} shows it` : ""})`);
|
|
844
|
+
}
|
|
845
|
+
if (c) {
|
|
846
|
+
if (c.permission) lines.push(` - needs the Permission "${c.permission.needs}": ${c.permission.held ? c.permission.why ? `you hold it (${c.permission.why})` : "your Role holds it" : `held by ${heldByNames(c.permission.heldBy).join(", ") || "no Role"}, not you: ask, do not act`}`);
|
|
847
|
+
if (c.approvalNeeded) lines.push(" - needs an Approval before it runs: request it and stop");
|
|
848
|
+
if (c.refused) lines.push(` - api.sb would refuse it now: ${c.refused}`);
|
|
849
|
+
}
|
|
850
|
+
return lines;
|
|
851
|
+
}
|
|
852
|
+
function renderPrompt(w, o = {}) {
|
|
853
|
+
const bin = o.bin ?? "npx startup-builder";
|
|
854
|
+
const domains = w.startups.map((s) => s.domain);
|
|
855
|
+
const roles = [...new Set(w.startups.flatMap((s) => s.roles.map((r) => r.name)))];
|
|
856
|
+
const out = [];
|
|
857
|
+
out.push(`# Worklist: ${w.worker.name}${roles.length ? ` (${roles.join(", ")})` : ""} \xB7 ${domains.join(", ")} \xB7 ${w.tasks.length} of ${w.open} open Tasks`);
|
|
858
|
+
out.push("");
|
|
859
|
+
out.push(
|
|
860
|
+
`You are ${w.worker.name}${w.worker.kind ? `, ${w.worker.kind === "Agent" ? "an agent" : "a"} Worker` : ""} in ${domains.join(" and ")}${roles.length ? `, filling the ${roles.join(" and ")} Role` : ""}. Work the Tasks below in order. Each one is already decided and assigned to you: do not re-plan them, and do not pick up other work.`
|
|
861
|
+
);
|
|
862
|
+
out.push("");
|
|
863
|
+
out.push("## Rules");
|
|
864
|
+
out.push('- **A Task closes when the Event that satisfies it happens** (its "closes on" line). Never mark a Task done by hand and never call `complete` on it. Do the work, then invoke the Verb that emits that Event.');
|
|
865
|
+
out.push(
|
|
866
|
+
w.source === "api.sb" ? `- Close a Task with \`${bin} close <task $id>\`: it invokes the Verb api.sb names for it, as a preview (try Mode, nothing stored) until you add \`--do\`. Every call carries an Idempotency-Key, so repeating a call never runs it twice.` : `- Invoke a Verb with \`${bin} do <Noun.verb> <$id> --startup <domain>\`. Add \`--preview\` first to see what it would do and whether it is allowed. Every call carries an Idempotency-Key, so repeating a call never runs it twice.`
|
|
867
|
+
);
|
|
868
|
+
out.push("- Stay inside your Permissions and Limits (below). Where a Verb needs a Permission you do not hold or an Approval, stop that Task and say so; do not work around it.");
|
|
869
|
+
out.push("- Use the Startup's own words (its glossary is below) in everything you write.");
|
|
870
|
+
out.push('- Read any `$id` with `curl -H "accept: application/json" <$id>` (or `?md` for Markdown) when you need more about a Thing.');
|
|
871
|
+
out.push("- End with a report, one line per Task: what you did, the Verb you invoked, and the Action URL it returned (or why you stopped).");
|
|
872
|
+
for (const r of w.rules ?? []) out.push(`- ${r}`);
|
|
873
|
+
if (w.clock) out.push(`- ${w.clock}`);
|
|
874
|
+
out.push("");
|
|
875
|
+
out.push("## The batch");
|
|
876
|
+
let n = 0;
|
|
877
|
+
for (const c of w.batch) {
|
|
878
|
+
out.push("");
|
|
879
|
+
out.push(`### ${c.startup} \u203A ${c.stepTitle ?? c.step}`);
|
|
880
|
+
for (const t2 of c.tasks) out.push(...taskLines(t2, ++n, bin));
|
|
881
|
+
}
|
|
882
|
+
if (w.more) out.push("", `(${w.more} more open Tasks wait after this batch: they are for a later worklist, not this run.)`);
|
|
883
|
+
if (w.planned?.length) {
|
|
884
|
+
out.push("", "## Planned (no Task yet: propose Tasks for these, do not start them unasked)");
|
|
885
|
+
for (const p of w.planned) out.push(`- ${p.title ?? p.name ?? p.step ?? JSON.stringify(p)}${p.step && (p.title ?? p.name) ? ` (${p.step})` : ""}`);
|
|
886
|
+
}
|
|
887
|
+
for (const s of w.startups) {
|
|
888
|
+
for (const r of s.roles) {
|
|
889
|
+
out.push("");
|
|
890
|
+
out.push(`## Your desk: ${r.name} in ${s.domain}`);
|
|
891
|
+
if (r.owns) out.push(`Owns: ${r.owns}.`);
|
|
892
|
+
if (r.summary.length) out.push(...r.summary.map((l) => `- ${l}`));
|
|
893
|
+
if (r.permissions.length) {
|
|
894
|
+
out.push("", "Permissions:");
|
|
895
|
+
out.push(...r.permissions.map((p) => `- ${p}`));
|
|
896
|
+
}
|
|
897
|
+
if (r.limits.length) {
|
|
898
|
+
out.push("", "Limits:");
|
|
899
|
+
out.push(...r.limits.map((l) => `- ${l.name}${l.text ? `: ${l.text}` : ""}`));
|
|
900
|
+
}
|
|
901
|
+
if (r.tools.length) out.push("", `Tools: ${r.tools.join(", ")}`);
|
|
902
|
+
}
|
|
903
|
+
if (s.worker?.skills.length) out.push("", `Skills: ${s.worker.skills.join(", ")}`);
|
|
904
|
+
}
|
|
905
|
+
for (const s of w.startups) {
|
|
906
|
+
if (!s.glossary) continue;
|
|
907
|
+
out.push("");
|
|
908
|
+
out.push(`## Glossary: ${s.domain} (from ${s.glossaryUrl})`);
|
|
909
|
+
out.push("");
|
|
910
|
+
out.push(s.glossary.replace(/^(#{1,4}) /gm, (_, h) => `${"#".repeat(Math.min(h.length + 2, 6))} `));
|
|
911
|
+
}
|
|
912
|
+
return out.join("\n") + "\n";
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// src/next/run.ts
|
|
916
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
917
|
+
function forStartup(w, domain) {
|
|
918
|
+
const batch = w.batch.filter((c) => c.startup === domain);
|
|
919
|
+
return {
|
|
920
|
+
...w,
|
|
921
|
+
startups: w.startups.filter((s) => s.domain === domain),
|
|
922
|
+
batch,
|
|
923
|
+
tasks: batch.flatMap((c) => c.tasks),
|
|
924
|
+
open: w.startups.find((s) => s.domain === domain)?.open ?? w.open
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
async function runCloud(api, w, o) {
|
|
928
|
+
const f = o.fetch ?? fetch;
|
|
929
|
+
const out = [];
|
|
930
|
+
for (const s of w.startups) {
|
|
931
|
+
const sub = forStartup(w, s.domain);
|
|
932
|
+
const first = sub.tasks[0];
|
|
933
|
+
if (!first) continue;
|
|
934
|
+
const prompt = renderPrompt(sub, { bin: o.bin });
|
|
935
|
+
const priority = Math.max(...sub.tasks.map((t2) => t2.priority));
|
|
936
|
+
const id = randomUUID2();
|
|
937
|
+
const role = s.roles[0]?.name ?? first.role;
|
|
938
|
+
const viaApi = { url: api.url(`${first.$id}:work`), body: { prompt, priority, ...o.model ? { model: o.model } : {} } };
|
|
939
|
+
const viaRunner = {
|
|
940
|
+
url: `${o.runner}/sessions`,
|
|
941
|
+
body: { id, prompt, startup: s.domain, worker: w.worker.handle, role, priority, task: first.$id, verb: "work", ...o.model ? { model: o.model } : {} }
|
|
942
|
+
};
|
|
943
|
+
if (o.dryRun) {
|
|
944
|
+
out.push({ startup: s.domain, via: "none", request: viaApi, note: `dry run; fallback would be POST ${viaRunner.url}` });
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
const a = await api.post(viaApi.url, viaApi.body, { idempotencyKey: id });
|
|
948
|
+
if (a.ok) {
|
|
949
|
+
out.push({ startup: s.domain, via: "api.sb", status: a.status, url: a.json?.action?.$id ?? a.json?.links?.self, session: id, request: viaApi });
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
if (!o.runnerToken) {
|
|
953
|
+
out.push({
|
|
954
|
+
startup: s.domain,
|
|
955
|
+
via: "none",
|
|
956
|
+
status: a.status,
|
|
957
|
+
request: viaRunner,
|
|
958
|
+
note: `api.sb answered ${a.status} to POST ${viaApi.url} (not served yet); set SB_RUNNER_TOKEN to go to claude-runner directly`
|
|
959
|
+
});
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
const r = await f(viaRunner.url, {
|
|
963
|
+
method: "POST",
|
|
964
|
+
headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${o.runnerToken}` },
|
|
965
|
+
body: JSON.stringify(viaRunner.body)
|
|
966
|
+
});
|
|
967
|
+
const j = await r.json().catch(() => ({}));
|
|
968
|
+
out.push({ startup: s.domain, via: "claude-runner", status: r.status, url: r.headers.get("location") ?? j.session?.$id ?? j.links?.self, session: id, request: viaRunner });
|
|
969
|
+
}
|
|
970
|
+
return out;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// src/next/verb.ts
|
|
974
|
+
var COLLECTION = {
|
|
975
|
+
Task: "actions",
|
|
976
|
+
Issue: "actions",
|
|
977
|
+
Action: "actions",
|
|
978
|
+
Person: "people",
|
|
979
|
+
Startup: ""
|
|
980
|
+
};
|
|
981
|
+
function collectionOf(noun) {
|
|
982
|
+
if (noun in COLLECTION) return COLLECTION[noun];
|
|
983
|
+
const kebab = noun.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
984
|
+
if (/[^aeiou]y$/.test(kebab)) return `${kebab.slice(0, -1)}ies`;
|
|
985
|
+
if (/(s|x|z|ch|sh)$/.test(kebab)) return `${kebab}es`;
|
|
986
|
+
return `${kebab}s`;
|
|
987
|
+
}
|
|
988
|
+
function parseVerb(spec) {
|
|
989
|
+
const m = /^([A-Z][A-Za-z0-9]*)\.([a-z][A-Za-z0-9]*)$/.exec(spec);
|
|
990
|
+
if (!m) throw new Error(`name a Verb as Noun.verb (e.g. Offer.publish), not "${spec}"`);
|
|
991
|
+
return { noun: m[1], verb: m[2] };
|
|
992
|
+
}
|
|
993
|
+
function verbPath(spec, target, startup) {
|
|
994
|
+
const { noun, verb } = parseVerb(spec);
|
|
995
|
+
if (target && /^https?:\/\//.test(target)) return `${target}:${verb}`;
|
|
996
|
+
if (target && target.startsWith("/")) return `${target}:${verb}`;
|
|
997
|
+
if (!startup) throw new Error("name the Startup: --startup <domain> (or pass the target as a full $id)");
|
|
998
|
+
const coll = collectionOf(noun);
|
|
999
|
+
const base = `/${startup}${coll ? `/${coll}` : ""}`;
|
|
1000
|
+
return `${target ? `${base}/${encodeURIComponent(target)}` : base}:${verb}`;
|
|
1001
|
+
}
|
|
1002
|
+
function parseInput(pairs = [], json) {
|
|
1003
|
+
const out = json ? JSON.parse(json) : {};
|
|
1004
|
+
for (const p of pairs) {
|
|
1005
|
+
const i = p.indexOf("=");
|
|
1006
|
+
if (i < 1) throw new Error(`--input takes key=value, not "${p}"`);
|
|
1007
|
+
const k = p.slice(0, i);
|
|
1008
|
+
const v = p.slice(i + 1);
|
|
1009
|
+
out[k] = v === "true" ? true : v === "false" ? false : v !== "" && !Number.isNaN(Number(v)) ? Number(v) : v;
|
|
1010
|
+
}
|
|
1011
|
+
return out;
|
|
1012
|
+
}
|
|
1013
|
+
function readPreview(url, status, j) {
|
|
1014
|
+
j ??= {};
|
|
1015
|
+
return {
|
|
1016
|
+
url,
|
|
1017
|
+
status,
|
|
1018
|
+
...j.verb?.name ? { name: String(j.verb.name) } : {},
|
|
1019
|
+
...j.verb?.does ? { does: String(j.verb.does) } : {},
|
|
1020
|
+
...j.verb?.event ? { event: String(j.verb.event) } : {},
|
|
1021
|
+
...j.verb?.mode ? { mode: String(j.verb.mode) } : {},
|
|
1022
|
+
...j.checks?.state ? { state: String(j.checks.state) } : {},
|
|
1023
|
+
...j.action?.status === "Refused" ? { refused: String(j.action.reason ?? "refused") } : {},
|
|
1024
|
+
...j.checks?.permission?.needs ? { permission: { needs: String(j.checks.permission.needs), heldBy: Object.keys(j.checks.permission.heldBy ?? {}) } } : {},
|
|
1025
|
+
...typeof j.checks?.approval?.needed === "boolean" ? { approvalNeeded: j.checks.approval.needed } : {},
|
|
1026
|
+
inputs: Object.keys(j.input ?? {}),
|
|
1027
|
+
...j.error ? { error: String(j.error.message ?? j.error) } : {}
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
function noteOf(p) {
|
|
1031
|
+
const e = p.json?.error;
|
|
1032
|
+
const msg = e?.message ? `${e.code ? `${e.code}: ` : ""}${e.message}` : void 0;
|
|
1033
|
+
if (!p.ok && (p.status === 404 || p.status === 405)) return `api.sb does not take this Verb here (${p.status})${msg ? `: ${msg}` : ""}`;
|
|
1034
|
+
return msg;
|
|
1035
|
+
}
|
|
1036
|
+
async function doVerb(api, o) {
|
|
1037
|
+
const path = verbPath(o.spec, o.target, o.startup);
|
|
1038
|
+
const postPath = o.as ? `${path}${path.includes("?") ? "&" : "?"}as=${encodeURIComponent(o.as)}` : path;
|
|
1039
|
+
const g = await api.get(path);
|
|
1040
|
+
const preview2 = readPreview(g.url, g.status, g.json);
|
|
1041
|
+
if (o.previewOnly || !g.ok) return { preview: preview2, invoked: false };
|
|
1042
|
+
if (preview2.refused && !o.force) return { preview: preview2, invoked: false };
|
|
1043
|
+
const p = await api.post(postPath, { ...o.input ?? {} }, { idempotencyKey: o.idempotencyKey, ...o.mode ? { prefer: `mode=${o.mode}` } : {} });
|
|
1044
|
+
const action = p.json?.action?.$id;
|
|
1045
|
+
return {
|
|
1046
|
+
preview: preview2,
|
|
1047
|
+
invoked: p.ok,
|
|
1048
|
+
post: {
|
|
1049
|
+
status: p.status,
|
|
1050
|
+
idempotencyKey: p.idempotencyKey,
|
|
1051
|
+
body: p.json ?? p.text.slice(0, 500),
|
|
1052
|
+
...action ? { action } : {},
|
|
1053
|
+
...p.json?.action?.status ? { actionStatus: String(p.json.action.status) } : {},
|
|
1054
|
+
...p.json?.action?.says ? { says: String(p.json.action.says) } : {},
|
|
1055
|
+
...noteOf(p) ? { note: noteOf(p) } : {}
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// src/next/body.ts
|
|
1061
|
+
import { realpathSync, statSync } from "fs";
|
|
1062
|
+
import { resolve, sep } from "path";
|
|
1063
|
+
var VERDICTS = ["confirmed", "corrected", "unsure"];
|
|
1064
|
+
var isUrl = (s) => typeof s === "string" && /^https?:\/\/\S+$/.test(s);
|
|
1065
|
+
function coordinatesReviewed(body) {
|
|
1066
|
+
const reviews = Array.isArray(body) ? body : body?.reviews;
|
|
1067
|
+
const errors = [];
|
|
1068
|
+
if (!Array.isArray(reviews) || !reviews.length) return { errors: ["body: {reviews:[{field,verdict,value?,sources,note}]} with at least one review"] };
|
|
1069
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1070
|
+
reviews.forEach((r, i) => {
|
|
1071
|
+
const at = `reviews[${i}]`;
|
|
1072
|
+
if (!r || typeof r !== "object") return errors.push(`${at}: an object`);
|
|
1073
|
+
if (typeof r.field !== "string" || !r.field.trim()) errors.push(`${at}.field: a field name`);
|
|
1074
|
+
else if (seen.has(r.field)) errors.push(`${at}.field: ${r.field} twice`);
|
|
1075
|
+
else seen.add(r.field);
|
|
1076
|
+
if (!VERDICTS.includes(r.verdict)) errors.push(`${at}.verdict: ${VERDICTS.join("|")}`);
|
|
1077
|
+
if (r.verdict === "corrected" && (r.value === void 0 || r.value === null || r.value === "")) errors.push(`${at}.value: required when corrected`);
|
|
1078
|
+
if (!Array.isArray(r.sources) || r.sources.some((s) => !isUrl(s))) errors.push(`${at}.sources: an array of URLs`);
|
|
1079
|
+
else if (VERDICTS.includes(r.verdict) && r.verdict !== "unsure" && !r.sources.length) errors.push(`${at}.sources: at least one URL when ${r.verdict}`);
|
|
1080
|
+
if (typeof r.note !== "string") errors.push(`${at}.note: a string`);
|
|
1081
|
+
const extra = Object.keys(r).filter((k) => !["field", "verdict", "value", "sources", "note"].includes(k));
|
|
1082
|
+
if (extra.length) errors.push(`${at}: unknown ${extra.join(", ")}`);
|
|
1083
|
+
return void 0;
|
|
1084
|
+
});
|
|
1085
|
+
return errors.length ? { errors } : { body: Array.isArray(body) ? { reviews } : body, errors };
|
|
1086
|
+
}
|
|
1087
|
+
var SHAPES = {
|
|
1088
|
+
"Coordinates.reviewed": coordinatesReviewed
|
|
1089
|
+
};
|
|
1090
|
+
function checkBody(event, body) {
|
|
1091
|
+
const f = SHAPES[event];
|
|
1092
|
+
if (!f) return { body: body ?? {}, errors: [] };
|
|
1093
|
+
if (body === void 0) return { errors: [`${event} needs a body (--body <file|->)`] };
|
|
1094
|
+
return f(body);
|
|
1095
|
+
}
|
|
1096
|
+
function parseBody(text) {
|
|
1097
|
+
try {
|
|
1098
|
+
return JSON.parse(text);
|
|
1099
|
+
} catch (e) {
|
|
1100
|
+
throw new Error(`--body is not JSON: ${e.message}`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
var inside = (p, root) => p === root || p.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
1104
|
+
function batchBodyPath(p, workdir) {
|
|
1105
|
+
const no = (why) => new Error(`--body ${p}: refused inside a batch (${why}); write the body to a file in the working directory, or use --body -`);
|
|
1106
|
+
if (!p || p.split(/[\\/]+/).includes("..")) throw no("no '..'");
|
|
1107
|
+
const root = resolve(workdir);
|
|
1108
|
+
let realRoot;
|
|
1109
|
+
try {
|
|
1110
|
+
realRoot = realpathSync(root);
|
|
1111
|
+
} catch {
|
|
1112
|
+
throw no("the working directory is gone");
|
|
1113
|
+
}
|
|
1114
|
+
const abs = resolve(root, p);
|
|
1115
|
+
if (!inside(abs, root) && !inside(abs, realRoot)) throw no("outside the working directory");
|
|
1116
|
+
let real;
|
|
1117
|
+
try {
|
|
1118
|
+
real = realpathSync(abs);
|
|
1119
|
+
} catch {
|
|
1120
|
+
throw new Error(`--body ${p}: no such file in the working directory`);
|
|
1121
|
+
}
|
|
1122
|
+
if (!inside(real, realRoot)) throw no("a link out of the working directory");
|
|
1123
|
+
if (!statSync(real).isFile()) throw no("not a regular file");
|
|
1124
|
+
return real;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// src/next/agents.ts
|
|
1128
|
+
import { spawn as nodeSpawn } from "child_process";
|
|
1129
|
+
|
|
1130
|
+
// src/next/models.ts
|
|
1131
|
+
var DEFAULT_MODEL = "claude-opus-5-5";
|
|
1132
|
+
|
|
1133
|
+
// src/next/agents.ts
|
|
1134
|
+
var CLAUDE_TOOLS = [
|
|
1135
|
+
"Bash(startup-builder close:*)",
|
|
1136
|
+
"Bash(startup-builder show:*)",
|
|
1137
|
+
"Bash(startup-builder prompt)",
|
|
1138
|
+
"Bash(startup-builder versions:*)",
|
|
1139
|
+
// the CLI's own help, read-only: `close --help` is already under `close:*`, these are the bare forms (#376)
|
|
1140
|
+
"Bash(startup-builder --help)",
|
|
1141
|
+
"Bash(startup-builder help:*)",
|
|
1142
|
+
"Read",
|
|
1143
|
+
"Write",
|
|
1144
|
+
"Edit",
|
|
1145
|
+
"Glob",
|
|
1146
|
+
"Grep",
|
|
1147
|
+
"WebFetch",
|
|
1148
|
+
"WebSearch"
|
|
1149
|
+
];
|
|
1150
|
+
function printRunner(o = {}) {
|
|
1151
|
+
const out = o.out ?? ((s) => process.stdout.write(s.endsWith("\n") ? s : `${s}
|
|
1152
|
+
`));
|
|
1153
|
+
return async (r) => {
|
|
1154
|
+
out(r.prompt);
|
|
1155
|
+
while (!r.signal.aborted) {
|
|
1156
|
+
if (await r.done()) return { exitCode: 0, note: "every Task closed" };
|
|
1157
|
+
await new Promise((res) => {
|
|
1158
|
+
const h = setTimeout(res, o.pollMs ?? 5e3);
|
|
1159
|
+
r.signal.addEventListener("abort", () => (clearTimeout(h), res()), { once: true });
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
return { exitCode: null };
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
var CLAUDE_BUILTINS = ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebFetch", "WebSearch"];
|
|
1166
|
+
function claudeArgs(model = DEFAULT_MODEL) {
|
|
1167
|
+
return [
|
|
1168
|
+
"-p",
|
|
1169
|
+
"--model",
|
|
1170
|
+
model,
|
|
1171
|
+
"--restricted",
|
|
1172
|
+
"--strict-mcp-config",
|
|
1173
|
+
"--permission-mode",
|
|
1174
|
+
"dontAsk",
|
|
1175
|
+
"--tools",
|
|
1176
|
+
...CLAUDE_BUILTINS,
|
|
1177
|
+
"--allowedTools",
|
|
1178
|
+
...CLAUDE_TOOLS,
|
|
1179
|
+
"--output-format",
|
|
1180
|
+
"stream-json",
|
|
1181
|
+
"--verbose"
|
|
1182
|
+
];
|
|
1183
|
+
}
|
|
1184
|
+
function resultNote(streamJson) {
|
|
1185
|
+
const lines = streamJson.trim().split("\n").reverse();
|
|
1186
|
+
for (const l of lines) {
|
|
1187
|
+
try {
|
|
1188
|
+
const e = JSON.parse(l);
|
|
1189
|
+
if (e.type !== "result") continue;
|
|
1190
|
+
const bits = [e.subtype, e.num_turns !== void 0 ? `${e.num_turns} turns` : "", e.duration_ms ? `${Math.round(e.duration_ms / 1e3)}s` : ""];
|
|
1191
|
+
return bits.filter(Boolean).join(" \xB7 ");
|
|
1192
|
+
} catch {
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return void 0;
|
|
1196
|
+
}
|
|
1197
|
+
function processRunner(cmd, args, o) {
|
|
1198
|
+
const sp = o.spawn ?? nodeSpawn;
|
|
1199
|
+
return (r) => new Promise((resolve2, reject) => {
|
|
1200
|
+
let tail = "";
|
|
1201
|
+
const child = sp(cmd, args, { cwd: r.cwd, env: r.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
1202
|
+
const kill = () => child.kill("SIGTERM");
|
|
1203
|
+
r.signal.addEventListener("abort", kill, { once: true });
|
|
1204
|
+
child.stdout?.on("data", (b) => {
|
|
1205
|
+
const s = b.toString();
|
|
1206
|
+
r.log.out(s);
|
|
1207
|
+
tail = (tail + s).slice(-64e3);
|
|
1208
|
+
});
|
|
1209
|
+
child.stderr?.on("data", (b) => r.log.err(b.toString()));
|
|
1210
|
+
child.once("error", (e) => {
|
|
1211
|
+
r.signal.removeEventListener("abort", kill);
|
|
1212
|
+
reject(e.code === "ENOENT" ? new Error(`${cmd} is not on PATH; install it, or use --agent print`) : e);
|
|
1213
|
+
});
|
|
1214
|
+
child.once("close", (code) => {
|
|
1215
|
+
r.signal.removeEventListener("abort", kill);
|
|
1216
|
+
resolve2({ exitCode: code, note: o.note?.(tail) });
|
|
1217
|
+
});
|
|
1218
|
+
child.stdin?.end(r.prompt);
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
function claudeRunner(o = {}) {
|
|
1222
|
+
return processRunner("claude", claudeArgs(o.model), { spawn: o.spawn, note: resultNote });
|
|
1223
|
+
}
|
|
1224
|
+
function codexRunner(o = {}) {
|
|
1225
|
+
return processRunner("codex", ["exec", ...o.model ? ["--model", o.model] : [], "-"], { spawn: o.spawn });
|
|
1226
|
+
}
|
|
1227
|
+
function agentRunner(name, o = {}) {
|
|
1228
|
+
if (name === "print") return printRunner(o);
|
|
1229
|
+
if (name === "claude") return claudeRunner(o);
|
|
1230
|
+
if (name === "codex") return codexRunner(o);
|
|
1231
|
+
throw new Error(`--agent ${name}: expected print, claude or codex`);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// src/next/lease.ts
|
|
1235
|
+
import { chmodSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
1236
|
+
import { homedir as homedir2 } from "os";
|
|
1237
|
+
import { join as join2 } from "path";
|
|
1238
|
+
function configDir(env = process.env) {
|
|
1239
|
+
return join2(env.XDG_CONFIG_HOME || join2(homedir2(), ".config"), "startup-builder");
|
|
1240
|
+
}
|
|
1241
|
+
function leasesDir(env = process.env) {
|
|
1242
|
+
return env.SB_LEASES || join2(configDir(env), "leases");
|
|
1243
|
+
}
|
|
1244
|
+
var slug = (batch) => batch.replace(/^https?:\/\//, "").replace(/[^A-Za-z0-9._-]+/g, "_").slice(-180);
|
|
1245
|
+
var Leases = class {
|
|
1246
|
+
constructor(dir, now = Date.now, env = process.env) {
|
|
1247
|
+
this.dir = dir;
|
|
1248
|
+
this.now = now;
|
|
1249
|
+
this.env = env;
|
|
1250
|
+
}
|
|
1251
|
+
/** the container-mode lease: in memory, never on disk */
|
|
1252
|
+
memory;
|
|
1253
|
+
ensure() {
|
|
1254
|
+
mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
1255
|
+
}
|
|
1256
|
+
write(name, data) {
|
|
1257
|
+
this.ensure();
|
|
1258
|
+
const f = join2(this.dir, name);
|
|
1259
|
+
writeFileSync(f, JSON.stringify(data, null, 2), { mode: 384 });
|
|
1260
|
+
chmodSync(f, 384);
|
|
1261
|
+
}
|
|
1262
|
+
file(batch) {
|
|
1263
|
+
return join2(this.dir, `${slug(batch)}.json`);
|
|
1264
|
+
}
|
|
1265
|
+
save(l) {
|
|
1266
|
+
if (l.held === "dispatcher" || this.memory && l.batch === this.memory.batch) {
|
|
1267
|
+
this.memory = l;
|
|
1268
|
+
return l;
|
|
1269
|
+
}
|
|
1270
|
+
this.write(`${slug(l.batch)}.json`, l);
|
|
1271
|
+
return l;
|
|
1272
|
+
}
|
|
1273
|
+
/** keep a lease the dispatcher holds (container mode): memory only, never written */
|
|
1274
|
+
adopt(l) {
|
|
1275
|
+
this.memory = { ...l, held: "dispatcher" };
|
|
1276
|
+
return this.memory;
|
|
1277
|
+
}
|
|
1278
|
+
/** the SB_BATCH pin, if any */
|
|
1279
|
+
get pin() {
|
|
1280
|
+
return this.env.SB_BATCH || void 0;
|
|
1281
|
+
}
|
|
1282
|
+
/** SB_BATCH is set and no lease file holds it: the dispatcher's lease (container mode) is what is needed */
|
|
1283
|
+
needsAdoption() {
|
|
1284
|
+
const pin = this.pin;
|
|
1285
|
+
return !!pin && !this.get(pin);
|
|
1286
|
+
}
|
|
1287
|
+
all() {
|
|
1288
|
+
let names;
|
|
1289
|
+
try {
|
|
1290
|
+
names = readdirSync(this.dir);
|
|
1291
|
+
} catch {
|
|
1292
|
+
return [];
|
|
1293
|
+
}
|
|
1294
|
+
const out = [];
|
|
1295
|
+
for (const n of names) {
|
|
1296
|
+
if (!n.endsWith(".json") || n.startsWith("last-")) continue;
|
|
1297
|
+
try {
|
|
1298
|
+
const l = JSON.parse(readFileSync(join2(this.dir, n), "utf8"));
|
|
1299
|
+
if (l?.batch && l.token) out.push(l);
|
|
1300
|
+
} catch {
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return out.sort((a, b) => b.claimedAt.localeCompare(a.claimedAt));
|
|
1304
|
+
}
|
|
1305
|
+
/** a dispatcher's lease is live until api.sb says it ended (409 lease_ended): the dispatcher renews it, not us */
|
|
1306
|
+
live(l) {
|
|
1307
|
+
return l.held === "dispatcher" || Date.parse(l.until) > this.now();
|
|
1308
|
+
}
|
|
1309
|
+
/** a batch by `$id`, path, or a suffix of it */
|
|
1310
|
+
get(ref) {
|
|
1311
|
+
const r = ref.replace(/^https?:\/\/[^/]+\//, "").replace(/^\/+/, "");
|
|
1312
|
+
const hit = (l) => l.batch === ref || l.batch.endsWith(`/${r}`);
|
|
1313
|
+
if (this.memory && hit(this.memory)) return this.memory;
|
|
1314
|
+
return this.all().find(hit);
|
|
1315
|
+
}
|
|
1316
|
+
/** SB_BATCH, else the newest lease that has not expired */
|
|
1317
|
+
current() {
|
|
1318
|
+
const pin = this.env.SB_BATCH;
|
|
1319
|
+
if (pin) return this.get(pin);
|
|
1320
|
+
return this.all().find((l) => this.live(l));
|
|
1321
|
+
}
|
|
1322
|
+
/** the live lease whose batch holds this Task */
|
|
1323
|
+
holding(task) {
|
|
1324
|
+
const pin = this.env.SB_BATCH ? this.get(this.env.SB_BATCH) : void 0;
|
|
1325
|
+
const pool = pin ? [pin] : this.all();
|
|
1326
|
+
return pool.find((l) => this.live(l) && l.tasks.includes(task));
|
|
1327
|
+
}
|
|
1328
|
+
/** re-read, merge, write: another process (the agent's own CLI) may have written the file since it was read */
|
|
1329
|
+
update(batch, patch) {
|
|
1330
|
+
const l = this.get(batch);
|
|
1331
|
+
if (!l) return void 0;
|
|
1332
|
+
patch(l);
|
|
1333
|
+
return this.save(l);
|
|
1334
|
+
}
|
|
1335
|
+
markClosed(batch, task) {
|
|
1336
|
+
return this.update(batch, (l) => {
|
|
1337
|
+
if (!l.closed.includes(task)) l.closed.push(task);
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
remove(batch) {
|
|
1341
|
+
if (this.memory && this.memory.batch === batch) {
|
|
1342
|
+
this.memory = void 0;
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
rmSync(this.file(batch), { force: true });
|
|
1346
|
+
}
|
|
1347
|
+
/** the last listing a command printed, so its indexes resolve */
|
|
1348
|
+
remember(kind, ids) {
|
|
1349
|
+
this.write(`last-${kind}.json`, { at: new Date(this.now()).toISOString(), ids });
|
|
1350
|
+
}
|
|
1351
|
+
recall(kind) {
|
|
1352
|
+
try {
|
|
1353
|
+
return JSON.parse(readFileSync(join2(this.dir, `last-${kind}.json`), "utf8")).ids ?? [];
|
|
1354
|
+
} catch {
|
|
1355
|
+
return [];
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1360
|
+
// src/next/loop.ts
|
|
1361
|
+
import { appendFileSync, mkdirSync as mkdirSync2, mkdtempSync, writeFileSync as writeFileSync2, chmodSync as chmodSync2, rmSync as rmSync2 } from "fs";
|
|
1362
|
+
import { tmpdir } from "os";
|
|
1363
|
+
import { join as join3 } from "path";
|
|
1364
|
+
var DEFAULT_WALL_MINUTES = 30;
|
|
1365
|
+
var SCRUB = [
|
|
1366
|
+
// api.sb and claude-runner bearers, and where the caller keeps its leases
|
|
1367
|
+
"SB_TOKEN",
|
|
1368
|
+
"SB_BATCH",
|
|
1369
|
+
"SB_LEASES",
|
|
1370
|
+
"SB_RUNNER_TOKEN",
|
|
1371
|
+
// Anthropic: an API key, a gateway, or a cloud provider instead of the subscription
|
|
1372
|
+
"ANTHROPIC_API_KEY",
|
|
1373
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
1374
|
+
"ANTHROPIC_BASE_URL",
|
|
1375
|
+
"ANTHROPIC_CUSTOM_HEADERS",
|
|
1376
|
+
"ANTHROPIC_BEDROCK_BASE_URL",
|
|
1377
|
+
"ANTHROPIC_VERTEX_BASE_URL",
|
|
1378
|
+
"ANTHROPIC_FOUNDRY_API_KEY",
|
|
1379
|
+
"ANTHROPIC_FOUNDRY_BASE_URL",
|
|
1380
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
1381
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
1382
|
+
"CLAUDE_CODE_USE_FOUNDRY",
|
|
1383
|
+
"AWS_BEARER_TOKEN_BEDROCK",
|
|
1384
|
+
"AWS_ACCESS_KEY_ID",
|
|
1385
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
1386
|
+
"AWS_SESSION_TOKEN",
|
|
1387
|
+
"AWS_PROFILE",
|
|
1388
|
+
// codex logs in on its own (`codex login`); an API key would bill instead
|
|
1389
|
+
"OPENAI_API_KEY",
|
|
1390
|
+
"CODEX_API_KEY",
|
|
1391
|
+
// the parent Claude Code session, when `work` runs inside one
|
|
1392
|
+
"CLAUDECODE",
|
|
1393
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
1394
|
+
"CLAUDE_CODE_SSE_PORT"
|
|
1395
|
+
];
|
|
1396
|
+
function agentEnv(base, lease, api, binDir, configHome, workdir) {
|
|
1397
|
+
const env = {};
|
|
1398
|
+
for (const [k, v] of Object.entries(base)) if (v !== void 0 && !SCRUB.includes(k)) env[k] = v;
|
|
1399
|
+
env.SB_TOKEN = lease.token;
|
|
1400
|
+
env.SB_BATCH = lease.batch;
|
|
1401
|
+
env.SB_API = api;
|
|
1402
|
+
if (configHome) env.XDG_CONFIG_HOME = configHome;
|
|
1403
|
+
if (workdir) env.SB_WORKDIR = workdir;
|
|
1404
|
+
if (binDir) env.PATH = [binDir, ...(env.PATH ?? "").split(":").filter((p) => p.startsWith("/"))].join(":");
|
|
1405
|
+
return env;
|
|
1406
|
+
}
|
|
1407
|
+
function unlock(dir) {
|
|
1408
|
+
try {
|
|
1409
|
+
chmodSync2(dir, 448);
|
|
1410
|
+
} catch {
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
function agentPrompt(md, n) {
|
|
1414
|
+
const body = md.replace(/npx startup-builder/g, "startup-builder").trimEnd();
|
|
1415
|
+
return `${body}
|
|
1416
|
+
|
|
1417
|
+
---
|
|
1418
|
+
Run by \`startup-builder work\`: this batch is claimed and its lease renews itself. \`startup-builder\` is on your PATH and holds the lease: write a Task's JSON body to a file in this directory, then \`startup-builder close <1-${n}> --body <file>\` closes it (\`--body -\` reads stdin); \`startup-builder show <1-${n}>\` reads one. Do not claim or release; stop when every Task is closed or you cannot go on.
|
|
1419
|
+
`;
|
|
1420
|
+
}
|
|
1421
|
+
var ms = (a, b) => {
|
|
1422
|
+
const s = Math.round((b - a) / 1e3);
|
|
1423
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
|
1424
|
+
};
|
|
1425
|
+
var sleep = (t2, signal) => new Promise((resolve2) => {
|
|
1426
|
+
if (signal.aborted) return resolve2();
|
|
1427
|
+
const h = setTimeout(done, t2);
|
|
1428
|
+
function done() {
|
|
1429
|
+
clearTimeout(h);
|
|
1430
|
+
signal.removeEventListener("abort", done);
|
|
1431
|
+
resolve2();
|
|
1432
|
+
}
|
|
1433
|
+
signal.addEventListener("abort", done, { once: true });
|
|
1434
|
+
});
|
|
1435
|
+
async function work(s, o) {
|
|
1436
|
+
const now = o.now ?? Date.now;
|
|
1437
|
+
const t0 = now();
|
|
1438
|
+
const sum = { batches: 0, tasks: 0, closed: 0, errors: 0, ms: 0, stopped: "empty" };
|
|
1439
|
+
const stamp = () => new Date(now()).toISOString().slice(11, 19);
|
|
1440
|
+
const line = (x) => o.line(`${stamp()} ${x}`);
|
|
1441
|
+
mkdirSync2(o.logDir, { recursive: true, mode: 448 });
|
|
1442
|
+
const worked = /* @__PURE__ */ new Set();
|
|
1443
|
+
while (true) {
|
|
1444
|
+
if (o.signal.aborted) {
|
|
1445
|
+
sum.stopped = "signal";
|
|
1446
|
+
break;
|
|
1447
|
+
}
|
|
1448
|
+
if (o.maxBatches && sum.batches >= o.maxBatches) {
|
|
1449
|
+
sum.stopped = "max";
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
const c = await s.claim({ worker: o.worker, skip: worked }).catch((e) => ({ text: `claim: ${e.message}`, data: { taken: [] }, exit: 1 }));
|
|
1453
|
+
if (c.exit === 1) {
|
|
1454
|
+
line(c.text);
|
|
1455
|
+
sum.errors++;
|
|
1456
|
+
sum.stopped = "error";
|
|
1457
|
+
break;
|
|
1458
|
+
}
|
|
1459
|
+
const got = "lease" in c.data && c.data.lease ? s.leases.get(c.data.lease.batch) : void 0;
|
|
1460
|
+
if (!got) {
|
|
1461
|
+
if (!o.wait) {
|
|
1462
|
+
line(c.text);
|
|
1463
|
+
sum.stopped = "empty";
|
|
1464
|
+
break;
|
|
1465
|
+
}
|
|
1466
|
+
await sleep(o.pollMs ?? 3e4, o.signal);
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
worked.add(got.batch);
|
|
1470
|
+
sum.batches++;
|
|
1471
|
+
sum.tasks += got.tasks.length;
|
|
1472
|
+
const closed = await runBatch(s, o, got, line);
|
|
1473
|
+
sum.closed += closed.closed;
|
|
1474
|
+
sum.errors += closed.errors;
|
|
1475
|
+
}
|
|
1476
|
+
sum.ms = now() - t0;
|
|
1477
|
+
o.line(`work: ${sum.batches} batch${sum.batches === 1 ? "" : "es"}, ${sum.closed}/${sum.tasks} Tasks closed, ${ms(t0, now())}, ${sum.errors} error${sum.errors === 1 ? "" : "s"} (${sum.stopped})`);
|
|
1478
|
+
return sum;
|
|
1479
|
+
}
|
|
1480
|
+
async function runBatch(s, o, lease, line) {
|
|
1481
|
+
const now = o.now ?? Date.now;
|
|
1482
|
+
const t0 = now();
|
|
1483
|
+
let errors = 0;
|
|
1484
|
+
line(`claimed ${lease.startup} ${lease.step}: ${lease.tasks.length} Tasks, until ${s.when(lease.until)} (${s.short(lease.batch)})`);
|
|
1485
|
+
const idx = (t2) => lease.tasks.indexOf(t2) + 1;
|
|
1486
|
+
const slug2 = s.short(lease.batch).replace(/[^A-Za-z0-9._-]+/g, "_").slice(-120);
|
|
1487
|
+
const logBase = join3(o.logDir, `${new Date(t0).toISOString().replace(/[:.]/g, "-")}-${slug2}`);
|
|
1488
|
+
const log = { out: (c) => appendFileSync(`${logBase}.jsonl`, c), err: (c) => appendFileSync(`${logBase}.err.log`, c) };
|
|
1489
|
+
const p = await s.batchPrompt(lease);
|
|
1490
|
+
if (p.exit) {
|
|
1491
|
+
line(p.text);
|
|
1492
|
+
const r2 = await s.release({ batch: lease });
|
|
1493
|
+
line(r2.text);
|
|
1494
|
+
return { closed: 0, errors: 1 };
|
|
1495
|
+
}
|
|
1496
|
+
const root = o.tmpRoot ?? tmpdir();
|
|
1497
|
+
const cwd = mkdtempSync(join3(root, "sb-work-"));
|
|
1498
|
+
const home = mkdtempSync(join3(root, "sb-home-"));
|
|
1499
|
+
chmodSync2(home, 448);
|
|
1500
|
+
let binDir;
|
|
1501
|
+
if (o.selfCli) {
|
|
1502
|
+
binDir = join3(home, "bin");
|
|
1503
|
+
mkdirSync2(binDir);
|
|
1504
|
+
const shim = join3(binDir, "startup-builder");
|
|
1505
|
+
writeFileSync2(shim, `#!/bin/sh
|
|
1506
|
+
exec ${o.selfCli} "$@"
|
|
1507
|
+
`);
|
|
1508
|
+
chmodSync2(shim, 365);
|
|
1509
|
+
chmodSync2(binDir, 365);
|
|
1510
|
+
}
|
|
1511
|
+
const configHome = join3(home, "config");
|
|
1512
|
+
const mine = new Leases(leasesDir({ XDG_CONFIG_HOME: configHome }), now, { SB_BATCH: lease.batch });
|
|
1513
|
+
mine.save({ ...lease, closed: [...lease.closed] });
|
|
1514
|
+
const env = agentEnv(o.env ?? process.env, lease, s.base, binDir, configHome, cwd);
|
|
1515
|
+
const cleanup = () => {
|
|
1516
|
+
if (binDir) unlock(binDir);
|
|
1517
|
+
rmSync2(home, { recursive: true, force: true });
|
|
1518
|
+
rmSync2(cwd, { recursive: true, force: true });
|
|
1519
|
+
};
|
|
1520
|
+
const notAfter = t0 + (o.wallMinutes ?? DEFAULT_WALL_MINUTES) * 6e4;
|
|
1521
|
+
let capped = false;
|
|
1522
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1523
|
+
const watch = () => {
|
|
1524
|
+
const l = mine.get(lease.batch);
|
|
1525
|
+
for (const t2 of l?.closed ?? []) if (!seen.has(t2)) {
|
|
1526
|
+
seen.add(t2);
|
|
1527
|
+
line(`closed ${idx(t2)} (${seen.size}/${lease.tasks.length}) ${s.short(t2)}`);
|
|
1528
|
+
}
|
|
1529
|
+
return l;
|
|
1530
|
+
};
|
|
1531
|
+
const ac = new AbortController();
|
|
1532
|
+
const stopAgent = () => ac.abort();
|
|
1533
|
+
o.signal.addEventListener("abort", stopAgent, { once: true });
|
|
1534
|
+
const watcher = setInterval(watch, o.watchMs ?? 2e3);
|
|
1535
|
+
const capTimer = setTimeout(() => {
|
|
1536
|
+
capped = true;
|
|
1537
|
+
line(`wall-clock cap (${o.wallMinutes ?? DEFAULT_WALL_MINUTES}m) reached: stopping the agent`);
|
|
1538
|
+
ac.abort();
|
|
1539
|
+
}, Math.max(0, notAfter - now()));
|
|
1540
|
+
let lost = false;
|
|
1541
|
+
const renewer = setInterval(async () => {
|
|
1542
|
+
if (!mine.get(lease.batch) || capped) return;
|
|
1543
|
+
const r2 = await s.renew({ batch: lease, notAfter }).catch((e) => ({ text: `renew: ${e.message}`, exit: 1, data: {} }));
|
|
1544
|
+
const renewed = r2.exit ? void 0 : s.leases.get(lease.batch);
|
|
1545
|
+
if (renewed) mine.update(lease.batch, (x) => {
|
|
1546
|
+
x.token = renewed.token;
|
|
1547
|
+
x.until = renewed.until;
|
|
1548
|
+
});
|
|
1549
|
+
if (r2.exit) {
|
|
1550
|
+
line(r2.text);
|
|
1551
|
+
if (r2.data?.lost) {
|
|
1552
|
+
lost = true;
|
|
1553
|
+
ac.abort();
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}, o.renewMs ?? Math.max(5e3, (lease.leaseSeconds ?? 900) * 1e3 / 3));
|
|
1557
|
+
const fromApi = async () => {
|
|
1558
|
+
for (const t2 of await s.closedOf(lease).catch(() => [])) if (!seen.has(t2)) {
|
|
1559
|
+
seen.add(t2);
|
|
1560
|
+
line(`closed ${idx(t2)} (${seen.size}/${lease.tasks.length}) ${s.short(t2)}`);
|
|
1561
|
+
}
|
|
1562
|
+
};
|
|
1563
|
+
const done = async () => {
|
|
1564
|
+
const l = watch();
|
|
1565
|
+
if (!l || !l.tasks.every((t2) => l.closed.includes(t2))) await fromApi();
|
|
1566
|
+
return lease.tasks.every((t2) => seen.has(t2));
|
|
1567
|
+
};
|
|
1568
|
+
line(`agent ${o.agentName} started \xB7 log ${logBase}.jsonl`);
|
|
1569
|
+
const ta = now();
|
|
1570
|
+
let res;
|
|
1571
|
+
try {
|
|
1572
|
+
res = await o.agent({ prompt: agentPrompt(p.text, lease.tasks.length), env, cwd, batch: lease.batch, log, signal: ac.signal, done });
|
|
1573
|
+
} catch (e) {
|
|
1574
|
+
res = { exitCode: 1, note: e.message };
|
|
1575
|
+
}
|
|
1576
|
+
clearInterval(watcher);
|
|
1577
|
+
clearInterval(renewer);
|
|
1578
|
+
clearTimeout(capTimer);
|
|
1579
|
+
o.signal.removeEventListener("abort", stopAgent);
|
|
1580
|
+
watch();
|
|
1581
|
+
const exitText = res.exitCode === null ? "stopped" : `exited ${res.exitCode}`;
|
|
1582
|
+
line(`agent ${exitText} after ${ms(ta, now())}${res.note ? ` \xB7 ${res.note}` : ""}`);
|
|
1583
|
+
if (res.exitCode && !o.signal.aborted && !lost && !capped) errors++;
|
|
1584
|
+
let r;
|
|
1585
|
+
try {
|
|
1586
|
+
await fromApi();
|
|
1587
|
+
r = await s.release({ batch: lease });
|
|
1588
|
+
} finally {
|
|
1589
|
+
mine.remove(lease.batch);
|
|
1590
|
+
cleanup();
|
|
1591
|
+
}
|
|
1592
|
+
if (r.exit) errors++;
|
|
1593
|
+
const already = r.data?.already;
|
|
1594
|
+
line(r.exit ? r.text : `released${already ? " (lease had ended)" : ""} ${lease.startup} ${lease.step}: ${seen.size}/${lease.tasks.length} closed in ${ms(t0, now())}`);
|
|
1595
|
+
return { closed: seen.size, errors };
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// src/next/mcp.ts
|
|
1599
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
1600
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1601
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
1602
|
+
var S = { type: "string" };
|
|
1603
|
+
var N = { type: "number" };
|
|
1604
|
+
var B = { type: "boolean" };
|
|
1605
|
+
var str = (v) => v === void 0 || v === null ? void 0 : String(v);
|
|
1606
|
+
var num = (v) => v === void 0 || v === null || v === "" ? void 0 : Number(v);
|
|
1607
|
+
var list = (v) => v === void 0 ? void 0 : String(v).split(",").map((s) => s.trim()).filter(Boolean);
|
|
1608
|
+
var t = (name, description, properties, run, required) => ({
|
|
1609
|
+
name,
|
|
1610
|
+
description,
|
|
1611
|
+
inputSchema: { type: "object", properties, ...required ? { required } : {} },
|
|
1612
|
+
run
|
|
1613
|
+
});
|
|
1614
|
+
var TOOLS = [
|
|
1615
|
+
t("queue", "Open batches, best first.", { worker: S, limit: N }, (s, a) => s.queue({ worker: str(a.worker), limit: num(a.limit) })),
|
|
1616
|
+
t("claim", "Claim a batch (n from queue); default the best free one.", { batch: S, worker: S }, (s, a) => s.claim({ ref: str(a.batch), worker: str(a.worker) })),
|
|
1617
|
+
t("prompt", "The claimed batch's prompt, or a named Prompt.", { name: S }, async (s, a) => a.set !== void 0 ? { text: "prompt: set is an Owner tool, off on this server (startup-builder mcp --owner)", data: null, exit: 1 } : s.prompt({ name: str(a.name) })),
|
|
1618
|
+
t("show", "Read a Task or Thing: index, path or $id.", { ref: S }, (s, a) => s.show({ ref: String(a.ref) }), ["ref"]),
|
|
1619
|
+
t("close", "Close a Task via its Verb. In a claimed batch it runs; try:true previews.", { task: S, body: {}, try: B }, (s, a) => s.close({ ref: String(a.task), body: a.body, mode: a.try ? "try" : void 0 }), ["task"]),
|
|
1620
|
+
t("renew", "Extend the lease.", {}, (s) => s.renew()),
|
|
1621
|
+
t("release", "Give the batch back; open Tasks requeue.", {}, (s) => s.release()),
|
|
1622
|
+
t("worklist", "A Worker's open Tasks.", { worker: S, startup: S, limit: N, page: N }, (s, a) => s.worklist({ worker: String(a.worker), startups: list(a.startup), limit: num(a.limit), page: num(a.page) }), ["worker"]),
|
|
1623
|
+
t("versions", "A Thing's versions.", { ref: S }, (s, a) => s.versions({ ref: String(a.ref) }), ["ref"]),
|
|
1624
|
+
t("changes", "Recent writes.", { worker: S, startup: S, limit: N }, (s, a) => s.changes({ worker: str(a.worker), startup: str(a.startup), limit: num(a.limit) }))
|
|
1625
|
+
];
|
|
1626
|
+
var OWNER_TOOLS = [
|
|
1627
|
+
t("prompt", "The claimed batch's prompt, or a named Prompt; set: its new text (Owner).", { name: S, set: S }, (s, a) => s.prompt({ name: str(a.name), set: str(a.set) })),
|
|
1628
|
+
t("revert", "Revert a Thing to a version (Owner); do:true runs it.", { ref: S, to: S, do: B }, (s, a) => s.revert({ ref: String(a.ref), to: String(a.to), mode: a.do ? "do" : "try" }), ["ref", "to"])
|
|
1629
|
+
];
|
|
1630
|
+
function toolsFor(o = {}) {
|
|
1631
|
+
if (!o.owner) return TOOLS;
|
|
1632
|
+
const own = new Map(OWNER_TOOLS.map((x) => [x.name, x]));
|
|
1633
|
+
return [...TOOLS.map((x) => own.get(x.name) ?? x), ...OWNER_TOOLS.filter((x) => !TOOLS.some((y) => y.name === x.name))];
|
|
1634
|
+
}
|
|
1635
|
+
function mcpServer(session, version, o = {}) {
|
|
1636
|
+
const tools = toolsFor(o);
|
|
1637
|
+
const server = new Server({ name: "startup-builder", version }, { capabilities: { tools: {} } });
|
|
1638
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: tools.map(({ run: _r, ...d }) => d) }));
|
|
1639
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
1640
|
+
const def = tools.find((x) => x.name === req.params.name);
|
|
1641
|
+
if (!def) return { content: [{ type: "text", text: `no tool ${req.params.name}` }], isError: true };
|
|
1642
|
+
try {
|
|
1643
|
+
const out = await def.run(session, req.params.arguments ?? {});
|
|
1644
|
+
return { content: [{ type: "text", text: out.text }], ...out.exit === 1 || out.exit === 4 ? { isError: true } : {} };
|
|
1645
|
+
} catch (e) {
|
|
1646
|
+
return { content: [{ type: "text", text: e?.message ?? String(e) }], isError: true };
|
|
1647
|
+
}
|
|
1648
|
+
});
|
|
1649
|
+
return server;
|
|
1650
|
+
}
|
|
1651
|
+
async function serveStdio(session, version, o = {}) {
|
|
1652
|
+
await mcpServer(session, version, o).connect(new StdioServerTransport());
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// src/next/session.ts
|
|
1656
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1657
|
+
var LEASE_ENDED = 4;
|
|
1658
|
+
var isLeaseEnded = (r) => r.status === 409 && (r.json?.error?.code === "lease_ended" || /lease_ended/.test(r.text));
|
|
1659
|
+
function lineDelta(before, after) {
|
|
1660
|
+
const count = (xs) => xs.reduce((m, x) => m.set(x, (m.get(x) ?? 0) + 1), /* @__PURE__ */ new Map());
|
|
1661
|
+
const a = count(before.split("\n"));
|
|
1662
|
+
const b = count(after.split("\n"));
|
|
1663
|
+
let added = 0;
|
|
1664
|
+
let removed = 0;
|
|
1665
|
+
for (const [k, n] of b) added += Math.max(0, n - (a.get(k) ?? 0));
|
|
1666
|
+
for (const [k, n] of a) removed += Math.max(0, n - (b.get(k) ?? 0));
|
|
1667
|
+
return `+${added} \u2212${removed} lines`;
|
|
1668
|
+
}
|
|
1669
|
+
function jwtClaims(t2) {
|
|
1670
|
+
const p = t2.split(".");
|
|
1671
|
+
if (p.length !== 3) return void 0;
|
|
1672
|
+
try {
|
|
1673
|
+
return JSON.parse(Buffer.from(p[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
1674
|
+
} catch {
|
|
1675
|
+
return void 0;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
var last = (u) => u ? u.split("?")[0].split("/").filter(Boolean).pop() ?? u : "";
|
|
1679
|
+
var isIndex = (s) => /^\d{1,3}$/.test(s);
|
|
1680
|
+
var Session = class {
|
|
1681
|
+
base;
|
|
1682
|
+
leases;
|
|
1683
|
+
d;
|
|
1684
|
+
constructor(d) {
|
|
1685
|
+
this.d = d;
|
|
1686
|
+
this.base = d.base.replace(/\/+$/, "");
|
|
1687
|
+
this.leases = d.leases;
|
|
1688
|
+
}
|
|
1689
|
+
// ---- plumbing ---------------------------------------------------------------------------------------------------
|
|
1690
|
+
/** a client on the base, carrying `c` only if it belongs to the base's origin (Api drops a token bound elsewhere) */
|
|
1691
|
+
api(c) {
|
|
1692
|
+
const cred = this.cred(c);
|
|
1693
|
+
return new Api({ base: this.base, token: cred?.token, bind: cred?.api, fetch: this.d.fetch, userAgent: this.d.userAgent });
|
|
1694
|
+
}
|
|
1695
|
+
cred(c) {
|
|
1696
|
+
if (!c) return void 0;
|
|
1697
|
+
return typeof c === "string" ? { token: c, api: this.base } : c;
|
|
1698
|
+
}
|
|
1699
|
+
/** a lease's token, bound to the API it was claimed on */
|
|
1700
|
+
leaseCred(l) {
|
|
1701
|
+
return { token: l.token, api: l.api || originOf(l.batch) || this.base };
|
|
1702
|
+
}
|
|
1703
|
+
now = () => (this.d.now ?? Date.now)();
|
|
1704
|
+
get env() {
|
|
1705
|
+
return this.d.env ?? process.env;
|
|
1706
|
+
}
|
|
1707
|
+
adopting;
|
|
1708
|
+
/**
|
|
1709
|
+
* Container mode: SB_BATCH is set and no lease file holds it, so build the lease the dispatcher holds from
|
|
1710
|
+
* `GET <SB_BATCH>`, in memory only. Undefined when there is nothing to do or it worked; else why not (exit 1, or 4 when
|
|
1711
|
+
* the lease has ended).
|
|
1712
|
+
*/
|
|
1713
|
+
ensureLease() {
|
|
1714
|
+
if (!this.leases.needsAdoption()) return Promise.resolve(void 0);
|
|
1715
|
+
return this.adopting ??= this.adopt().then((r) => {
|
|
1716
|
+
if (r) this.adopting = void 0;
|
|
1717
|
+
return r;
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
async adopt() {
|
|
1721
|
+
const pin = this.leases.pin;
|
|
1722
|
+
const token = this.env.SB_TOKEN || void 0;
|
|
1723
|
+
if (!token) return this.none(`SB_BATCH is set (${this.short(pin)}) but no lease file holds it and SB_TOKEN is unset: claim it, or unset SB_BATCH`);
|
|
1724
|
+
const id = this.api().url(pin.replace(/^\/+/, ""));
|
|
1725
|
+
const r = await this.api(token).get(id);
|
|
1726
|
+
if (isLeaseEnded(r)) return this.ended(r, `batch ${this.short(id)}`);
|
|
1727
|
+
if (!r.ok) return this.fail(r, `batch ${this.short(id)}`);
|
|
1728
|
+
const b = r.json?.batch ?? (r.json?.$id ? r.json : void 0);
|
|
1729
|
+
const raw = Array.isArray(b?.tasks) ? b.tasks : [];
|
|
1730
|
+
const tasks = raw.map((t2) => typeof t2 === "string" ? t2 : String(t2.$id)).filter(Boolean);
|
|
1731
|
+
if (!b || !tasks.length) return this.none(`batch ${this.short(id)}: api.sb answered ${r.status} with no Tasks`);
|
|
1732
|
+
const closed = raw.filter((t2) => typeof t2 === "object" && (t2.closedOn !== void 0 || t2.status === "Done")).map((t2) => String(t2.$id));
|
|
1733
|
+
const first = raw.find((t2) => typeof t2 === "object");
|
|
1734
|
+
this.leases.adopt({
|
|
1735
|
+
batch: String(b.$id ?? id),
|
|
1736
|
+
worker: last(b.worker) || String(b.worker ?? ""),
|
|
1737
|
+
startup: last(b.startup ?? first?.startup) || String(b.startup ?? first?.startup ?? ""),
|
|
1738
|
+
step: String(b.stepTitle ?? b.step ?? first?.stepTitle ?? first?.step ?? ""),
|
|
1739
|
+
...typeof b.priority === "number" ? { priority: b.priority } : {},
|
|
1740
|
+
tasks,
|
|
1741
|
+
prompt: String(b.prompt ?? `${b.$id ?? id}?md`),
|
|
1742
|
+
until: String(b.lease?.until ?? ""),
|
|
1743
|
+
token,
|
|
1744
|
+
session: String(b.lease?.session ?? this.env.SB_SESSION ?? ""),
|
|
1745
|
+
claimedAt: new Date(this.now()).toISOString(),
|
|
1746
|
+
leaseSeconds: 0,
|
|
1747
|
+
api: this.base,
|
|
1748
|
+
closed,
|
|
1749
|
+
held: "dispatcher"
|
|
1750
|
+
});
|
|
1751
|
+
return void 0;
|
|
1752
|
+
}
|
|
1753
|
+
/** 409 lease_ended: stop. A lease file for it goes (the lease is over); a dispatcher's lease is the dispatcher's. */
|
|
1754
|
+
ended(r, what, l) {
|
|
1755
|
+
if (l && l.held !== "dispatcher") this.leases.remove(l.batch);
|
|
1756
|
+
const e = r.json?.error;
|
|
1757
|
+
const msg = typeof e === "string" ? e : e?.message ?? "";
|
|
1758
|
+
return { text: `${what}: lease ended, stop${msg ? ` (${msg})` : ""}`, data: { status: r.status, code: "lease_ended", stop: true }, exit: LEASE_ENDED };
|
|
1759
|
+
}
|
|
1760
|
+
/** a `$id` shown relative to api.sb: `demo.startups.studio/barcoding.dev/actions/fix-fnc1` */
|
|
1761
|
+
short(u) {
|
|
1762
|
+
if (!u) return "";
|
|
1763
|
+
return u.startsWith(`${this.base}/`) ? u.slice(this.base.length + 1) : u;
|
|
1764
|
+
}
|
|
1765
|
+
/** `10:14Z` today, else `2026-09-28 10:14Z` */
|
|
1766
|
+
when(iso) {
|
|
1767
|
+
if (!iso) return "?";
|
|
1768
|
+
const d = new Date(iso);
|
|
1769
|
+
if (Number.isNaN(+d)) return iso;
|
|
1770
|
+
const s = d.toISOString();
|
|
1771
|
+
return s.slice(0, 10) === new Date(this.now()).toISOString().slice(0, 10) ? `${s.slice(11, 16)}Z` : `${s.slice(0, 10)} ${s.slice(11, 16)}Z`;
|
|
1772
|
+
}
|
|
1773
|
+
async baseToken(interactive = false) {
|
|
1774
|
+
const t2 = this.cred(await this.d.token());
|
|
1775
|
+
if (t2 || !interactive || !this.d.login) return t2;
|
|
1776
|
+
return this.cred(await this.d.login());
|
|
1777
|
+
}
|
|
1778
|
+
fail(r, what, data) {
|
|
1779
|
+
const e = r.json?.error;
|
|
1780
|
+
const msg = typeof e === "string" ? e : e?.message ?? (r.text && !r.json ? r.text.slice(0, 200) : "");
|
|
1781
|
+
const code = typeof e?.code === "string" ? ` ${e.code}` : "";
|
|
1782
|
+
return { text: `${what} ${r.status}${code}${msg ? `: ${msg}` : ""}`, data: data ?? r.json ?? { status: r.status }, exit: 1 };
|
|
1783
|
+
}
|
|
1784
|
+
none(what) {
|
|
1785
|
+
return { text: what, data: null, exit: 1 };
|
|
1786
|
+
}
|
|
1787
|
+
/** A Task (or Thing) ref as a `$id`: an index into the claimed batch (else the last worklist), a path, or a URL. */
|
|
1788
|
+
resolve(ref, o = {}) {
|
|
1789
|
+
const r = ref.trim();
|
|
1790
|
+
if (isIndex(r)) {
|
|
1791
|
+
const n = Number(r);
|
|
1792
|
+
if (o.kind === "batch") {
|
|
1793
|
+
const ids2 = this.leases.recall("queue");
|
|
1794
|
+
if (!ids2[n - 1]) throw new Error(`no batch ${n} in the last queue listing (${ids2.length}): run queue`);
|
|
1795
|
+
return ids2[n - 1];
|
|
1796
|
+
}
|
|
1797
|
+
const lease = this.leases.current();
|
|
1798
|
+
const ids = lease?.tasks ?? this.leases.recall("worklist");
|
|
1799
|
+
if (!ids[n - 1]) throw new Error(lease ? `the batch has Tasks 1-${ids.length}, not ${n}` : `no Task ${n}: claim a batch, or list a worklist first`);
|
|
1800
|
+
return ids[n - 1];
|
|
1801
|
+
}
|
|
1802
|
+
return this.api().url(r.replace(/^\/+/, ""));
|
|
1803
|
+
}
|
|
1804
|
+
label(ref, id) {
|
|
1805
|
+
return isIndex(ref.trim()) ? ref.trim() : this.short(id);
|
|
1806
|
+
}
|
|
1807
|
+
/** the token for a read of this `$id`: a lease that holds it, else SB_TOKEN/sign-in, else any live lease (it reads) */
|
|
1808
|
+
async readToken(id) {
|
|
1809
|
+
const held = this.leases.holding(id);
|
|
1810
|
+
if (held) return this.leaseCred(held);
|
|
1811
|
+
const lease = this.leases.current();
|
|
1812
|
+
if (lease && (id === lease.prompt || id.startsWith(lease.batch))) return this.leaseCred(lease);
|
|
1813
|
+
return await this.baseToken() ?? (lease && this.leases.live(lease) ? this.leaseCred(lease) : void 0);
|
|
1814
|
+
}
|
|
1815
|
+
// ---- the queue ----------------------------------------------------------------------------------------------------
|
|
1816
|
+
batchLine(b, i, withWorker) {
|
|
1817
|
+
const step = b.stepTitle ?? b.step;
|
|
1818
|
+
const where = b.startups && b.startups.length > 1 ? `${last(b.startups[0])}+${b.startups.length - 1}` : last(b.startup) || b.startup;
|
|
1819
|
+
return `${i} ${where} ${step} ${b.tasks.length} Tasks p${b.priority}${withWorker ? ` ${last(b.worker)}` : ""}`;
|
|
1820
|
+
}
|
|
1821
|
+
async listQueue(o = {}) {
|
|
1822
|
+
const q = new URLSearchParams({ limit: String(o.limit ?? 5) });
|
|
1823
|
+
const w = parseWorker(o.worker);
|
|
1824
|
+
if (w) q.set("worker", w);
|
|
1825
|
+
const r = await this.api(await this.baseToken()).get(`/queue?${q}`);
|
|
1826
|
+
return { r, batches: r.ok ? r.json?.batches ?? [] : [] };
|
|
1827
|
+
}
|
|
1828
|
+
/** `queue [for:w] [--limit n]`: open batches, highest priority first (claimed ones excluded by api.sb) */
|
|
1829
|
+
async queue(o = {}) {
|
|
1830
|
+
const { r, batches } = await this.listQueue(o);
|
|
1831
|
+
if (!r.ok) return this.fail(r, "queue");
|
|
1832
|
+
this.leases.remember("queue", batches.map((b) => b.$id));
|
|
1833
|
+
if (!batches.length) return { text: "queue empty", data: { batches }, exit: 3 };
|
|
1834
|
+
const withWorker = !o.worker && new Set(batches.map((b) => b.worker)).size > 1;
|
|
1835
|
+
return { text: batches.map((b, i) => this.batchLine(b, i + 1, withWorker)).join("\n"), data: { batches }, exit: 0 };
|
|
1836
|
+
}
|
|
1837
|
+
async batchOf(id, worker) {
|
|
1838
|
+
const { batches } = await this.listQueue({ worker, limit: 50 });
|
|
1839
|
+
const hit = batches.find((b2) => b2.$id === id);
|
|
1840
|
+
if (hit) return hit;
|
|
1841
|
+
const r = await this.api(await this.baseToken()).get(id);
|
|
1842
|
+
const b = r.json?.batch ?? (r.json?.$id ? r.json : void 0);
|
|
1843
|
+
return r.ok && b?.$id ? b : void 0;
|
|
1844
|
+
}
|
|
1845
|
+
/** POST one claim: the lease, 'taken' on 409, or the failure */
|
|
1846
|
+
async claimOne(b, token, leaseSeconds) {
|
|
1847
|
+
const session = this.d.session ?? randomUUID3();
|
|
1848
|
+
const url = b.claim || `${b.$id}:claim`;
|
|
1849
|
+
const r = await this.api(token).post(url, { leaseSeconds, session });
|
|
1850
|
+
if (r.status === 409) return "taken";
|
|
1851
|
+
if (!r.ok) return this.fail(r, `claim ${this.short(b.$id)}`);
|
|
1852
|
+
const l = r.json?.lease ?? {};
|
|
1853
|
+
if (!l.token) return this.none(`claim ${this.short(b.$id)}: api.sb answered ${r.status} with no lease token`);
|
|
1854
|
+
return this.leases.save({
|
|
1855
|
+
batch: b.$id,
|
|
1856
|
+
worker: last(b.worker) || b.worker,
|
|
1857
|
+
startup: last(b.startup) || b.startup,
|
|
1858
|
+
step: b.stepTitle ?? b.step,
|
|
1859
|
+
priority: b.priority,
|
|
1860
|
+
tasks: b.tasks,
|
|
1861
|
+
prompt: b.prompt,
|
|
1862
|
+
until: l.until ?? new Date(this.now() + leaseSeconds * 1e3).toISOString(),
|
|
1863
|
+
token: l.token,
|
|
1864
|
+
session,
|
|
1865
|
+
claimedAt: new Date(this.now()).toISOString(),
|
|
1866
|
+
leaseSeconds,
|
|
1867
|
+
api: this.base,
|
|
1868
|
+
closed: []
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
/** `claim [n|batch]`: a named batch, or (no argument) the best free one, falling through a 409 to the next */
|
|
1872
|
+
async claim(o = {}) {
|
|
1873
|
+
const leaseSeconds = o.leaseSeconds ?? this.d.leaseSeconds ?? 900;
|
|
1874
|
+
const token = await this.baseToken(true);
|
|
1875
|
+
let candidates;
|
|
1876
|
+
if (o.ref && o.ref !== "next") {
|
|
1877
|
+
const id = this.resolve(o.ref, { kind: "batch" });
|
|
1878
|
+
const b = await this.batchOf(id, o.worker);
|
|
1879
|
+
if (!b) return { text: `claim: no open batch ${this.short(id)}`, data: { taken: [] }, exit: 1 };
|
|
1880
|
+
candidates = [b];
|
|
1881
|
+
} else {
|
|
1882
|
+
const skip = o.skip ?? /* @__PURE__ */ new Set();
|
|
1883
|
+
const { r, batches } = await this.listQueue({ worker: o.worker, limit: Math.min(50, 5 + skip.size) });
|
|
1884
|
+
if (!r.ok) return this.fail(r, "queue", { taken: [] });
|
|
1885
|
+
candidates = batches.filter((b) => !skip.has(b.$id));
|
|
1886
|
+
}
|
|
1887
|
+
const taken = [];
|
|
1888
|
+
for (const b of candidates) {
|
|
1889
|
+
const got = await this.claimOne(b, token, leaseSeconds);
|
|
1890
|
+
if (got === "taken") {
|
|
1891
|
+
taken.push(b.$id);
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
if ("exit" in got) return { ...got, data: { taken } };
|
|
1895
|
+
const { token: _t, ...safe } = got;
|
|
1896
|
+
return { text: `claimed ${got.startup} ${got.step}: ${got.tasks.length} Tasks, until ${this.when(got.until)}`, data: { lease: safe, taken }, exit: 0 };
|
|
1897
|
+
}
|
|
1898
|
+
return { text: taken.length ? `nothing free: ${taken.length} taken` : "queue empty", data: { taken }, exit: 3 };
|
|
1899
|
+
}
|
|
1900
|
+
/** the lease as its file says now (another process may have written it), else the object passed */
|
|
1901
|
+
leaseOf(batch) {
|
|
1902
|
+
if (batch && typeof batch === "object") return this.leases.get(batch.batch) ?? batch;
|
|
1903
|
+
return batch ? this.leases.get(batch) : this.leases.current();
|
|
1904
|
+
}
|
|
1905
|
+
/** POST a lease Verb with the lease token, then SB_TOKEN/sign-in if api.sb refuses the lease token */
|
|
1906
|
+
async leasePost(l, verb, body) {
|
|
1907
|
+
let r = await this.api(this.leaseCred(l)).post(`${l.batch}:${verb}`, body);
|
|
1908
|
+
if (r.status === 401 || r.status === 403) {
|
|
1909
|
+
const t2 = await this.baseToken();
|
|
1910
|
+
if (t2 && t2.token !== l.token) r = await this.api(t2).post(`${l.batch}:${verb}`, body);
|
|
1911
|
+
}
|
|
1912
|
+
return r;
|
|
1913
|
+
}
|
|
1914
|
+
/** `renew`: extend the current lease; never past `notAfter` (epoch ms: the work loop's wall-clock cap) */
|
|
1915
|
+
async renew(o = {}) {
|
|
1916
|
+
const bad = await this.ensureLease();
|
|
1917
|
+
if (bad) return bad;
|
|
1918
|
+
const l = this.leaseOf(o.batch);
|
|
1919
|
+
if (!l) return this.none("no claimed batch");
|
|
1920
|
+
if (l.held === "dispatcher") return { text: "renew: the dispatcher holds this lease and renews it; nothing to do here", data: { dispatcher: true }, exit: 0 };
|
|
1921
|
+
let leaseSeconds = o.leaseSeconds ?? l.leaseSeconds ?? this.d.leaseSeconds ?? 900;
|
|
1922
|
+
if (o.notAfter !== void 0) {
|
|
1923
|
+
const left = Math.floor((o.notAfter - this.now()) / 1e3);
|
|
1924
|
+
if (left <= 0) return { text: "renew: the wall-clock cap is reached; not renewed", data: { capped: true }, exit: 1 };
|
|
1925
|
+
leaseSeconds = Math.min(leaseSeconds, left);
|
|
1926
|
+
}
|
|
1927
|
+
const r = await this.leasePost(l, "renew", { leaseSeconds, session: l.session });
|
|
1928
|
+
if (r.status === 404 || r.status === 409 || r.status === 410) {
|
|
1929
|
+
this.leases.remove(l.batch);
|
|
1930
|
+
return { ...this.fail(r, "lease lost"), data: { lost: true } };
|
|
1931
|
+
}
|
|
1932
|
+
if (!r.ok) return this.fail(r, "renew");
|
|
1933
|
+
const nl = r.json?.lease ?? {};
|
|
1934
|
+
let until = nl.until ?? new Date(this.now() + leaseSeconds * 1e3).toISOString();
|
|
1935
|
+
if (o.notAfter !== void 0 && Date.parse(until) > o.notAfter) until = new Date(o.notAfter).toISOString();
|
|
1936
|
+
const patch = (x) => {
|
|
1937
|
+
x.until = until;
|
|
1938
|
+
if (nl.token) x.token = nl.token;
|
|
1939
|
+
x.leaseSeconds = leaseSeconds;
|
|
1940
|
+
};
|
|
1941
|
+
patch(l);
|
|
1942
|
+
this.leases.update(l.batch, patch);
|
|
1943
|
+
return { text: `renewed until ${this.when(l.until)}`, data: { until: l.until }, exit: 0 };
|
|
1944
|
+
}
|
|
1945
|
+
/** `release`: give the batch back; its unclosed Tasks return to the queue */
|
|
1946
|
+
async release(o = {}) {
|
|
1947
|
+
const bad = await this.ensureLease();
|
|
1948
|
+
if (bad) return bad;
|
|
1949
|
+
const l = this.leaseOf(o.batch);
|
|
1950
|
+
if (!l) return this.none("no claimed batch");
|
|
1951
|
+
if (l.held === "dispatcher") return { text: "release: the dispatcher holds this lease and releases it when the session ends; nothing to do here", data: { closed: l.closed.length, returned: 0, dispatcher: true }, exit: 0 };
|
|
1952
|
+
const r = await this.leasePost(l, "release", { session: l.session });
|
|
1953
|
+
const closed = typeof r.json?.closed === "number" ? r.json.closed : l.closed.length;
|
|
1954
|
+
const returned = typeof r.json?.returned === "number" ? r.json.returned : l.tasks.length - closed;
|
|
1955
|
+
if (r.ok || r.status === 404 || r.status === 409 || r.status === 410) this.leases.remove(l.batch);
|
|
1956
|
+
if (r.status === 404 || r.status === 409 || r.status === 410) return { text: `released (lease had already ended): ${closed}/${l.tasks.length} closed`, data: { closed, returned, already: true }, exit: 0 };
|
|
1957
|
+
if (!r.ok) return this.fail(r, "release");
|
|
1958
|
+
return { text: `released ${l.startup} ${l.step}: ${closed}/${l.tasks.length} closed${returned ? `, ${returned} back on the queue` : ""}`, data: { closed, returned }, exit: 0 };
|
|
1959
|
+
}
|
|
1960
|
+
// ---- reading ------------------------------------------------------------------------------------------------------
|
|
1961
|
+
/** the claimed batch's prompt (its `?md`), verbatim */
|
|
1962
|
+
async batchPrompt(l) {
|
|
1963
|
+
const r = await this.api(this.leaseCred(l)).get(l.prompt, "text/markdown");
|
|
1964
|
+
if (isLeaseEnded(r)) return this.ended(r, "prompt", l);
|
|
1965
|
+
if (!r.ok) return this.fail(r, "prompt", { url: l.prompt });
|
|
1966
|
+
return { text: r.text.trimEnd(), data: { url: l.prompt }, exit: 0 };
|
|
1967
|
+
}
|
|
1968
|
+
/** `prompt [name]`: the claimed batch's prompt, or a versioned Prompt at /prompts/{name} */
|
|
1969
|
+
async prompt(o = {}) {
|
|
1970
|
+
if (o.set !== void 0) {
|
|
1971
|
+
if (!o.name) return this.none("prompt --set: name the Prompt (prompt <name> --set <file|->)");
|
|
1972
|
+
return this.promptSet({ name: o.name, text: o.set });
|
|
1973
|
+
}
|
|
1974
|
+
if (!o.name) {
|
|
1975
|
+
const bad = await this.ensureLease();
|
|
1976
|
+
if (bad) return bad;
|
|
1977
|
+
const l = this.leases.current();
|
|
1978
|
+
if (!l) return this.none("no claimed batch: claim first, or prompt <name>");
|
|
1979
|
+
return this.batchPrompt(l);
|
|
1980
|
+
}
|
|
1981
|
+
const path = /^https?:\/\//.test(o.name) || o.name.includes("/prompts/") ? o.name : `/prompts/${o.name.replace(/^\/+/, "")}`;
|
|
1982
|
+
const r = await this.api(await this.baseToken()).get(path);
|
|
1983
|
+
if (!r.ok) return this.fail(r, `prompt ${o.name}`);
|
|
1984
|
+
const p = r.json?.prompt ?? thingOf(r.json) ?? r.json ?? {};
|
|
1985
|
+
const text = p.text ?? p.body ?? p.content ?? p.template;
|
|
1986
|
+
if (typeof text === "string") return { text: text.trimEnd(), data: p, exit: 0 };
|
|
1987
|
+
const md = await this.api(await this.baseToken()).get(`${path}${path.includes("?") ? "&" : "?"}md`, "text/markdown");
|
|
1988
|
+
return md.ok ? { text: md.text.trimEnd(), data: p, exit: 0 } : this.fail(md, `prompt ${o.name}`);
|
|
1989
|
+
}
|
|
1990
|
+
/** is this bearer a batch's lease token (a lease we hold, the container's SB_TOKEN, or a JWT naming a batch)? */
|
|
1991
|
+
isLeaseToken(t2) {
|
|
1992
|
+
if (this.leases.all().some((l) => l.token === t2)) return true;
|
|
1993
|
+
if (this.leases.pin && this.env.SB_TOKEN === t2) return true;
|
|
1994
|
+
const c = jwtClaims(t2);
|
|
1995
|
+
return !!c && (c.batch !== void 0 || c.sid !== void 0);
|
|
1996
|
+
}
|
|
1997
|
+
/**
|
|
1998
|
+
* `prompt <name> --set <file|->`: a new version of a Prompt (POST /prompts/{name}:update {text}, an Idempotency-Key),
|
|
1999
|
+
* as the Owner: the stored sign-in (or an Owner's SB_TOKEN), never a lease token. Answers the new version and a
|
|
2000
|
+
* one-line summary of what changed; every batch claimed after it reads the new text.
|
|
2001
|
+
*/
|
|
2002
|
+
async promptSet(o) {
|
|
2003
|
+
const name = o.name.replace(/^https?:\/\/[^/]+/, "").replace(/^\/*(prompts\/)?/, "").replace(/[:?].*$/, "");
|
|
2004
|
+
const text = o.text.replace(/\s+$/, "");
|
|
2005
|
+
if (!text.trim()) return this.none(`prompt ${name} --set: the new text is empty`);
|
|
2006
|
+
const token = await this.baseToken(true);
|
|
2007
|
+
if (!token) return this.none(`prompt ${name} --set: sign in as the Owner (startup-builder login)`);
|
|
2008
|
+
if (this.isLeaseToken(token.token)) return this.none(`prompt ${name} --set: a lease token cannot edit prompts; sign in as the Owner (startup-builder login, without SB_TOKEN)`);
|
|
2009
|
+
const api = this.api(token);
|
|
2010
|
+
const path = `/prompts/${name}`;
|
|
2011
|
+
const cur = await api.get(path);
|
|
2012
|
+
if (!cur.ok) return this.fail(cur, `prompt ${name}`);
|
|
2013
|
+
const p = cur.json?.prompt ?? {};
|
|
2014
|
+
const before = String(p.text ?? "").replace(/\s+$/, "");
|
|
2015
|
+
const was = p.version ?? p.n;
|
|
2016
|
+
if (before === text) return { text: `prompt ${name} unchanged${was !== void 0 ? ` (v${was})` : ""}: nothing sent`, data: p, exit: 0 };
|
|
2017
|
+
const r = await api.post(`${path}:update`, { text }, { idempotencyKey: o.idempotencyKey });
|
|
2018
|
+
if (r.status === 403) return this.fail(r, `prompt ${name} --set (the Owner only)`);
|
|
2019
|
+
if (!r.ok) return this.fail(r, `prompt ${name} --set`);
|
|
2020
|
+
const a = r.json?.action ?? {};
|
|
2021
|
+
const v = a.version ?? (typeof was === "number" ? was + 1 : void 0);
|
|
2022
|
+
const replay = r.headers["idempotent-replayed"] === "true" ? " (replayed)" : "";
|
|
2023
|
+
return { text: `prompt ${name} ${was !== void 0 ? `v${was} \u2192 ` : ""}v${v ?? "?"}: ${lineDelta(before, text)}${replay}`, data: r.json ?? {}, exit: 0 };
|
|
2024
|
+
}
|
|
2025
|
+
/** `show <ref>`: a Task or Thing, compact (`md` for its `?md`) */
|
|
2026
|
+
async show(o) {
|
|
2027
|
+
const bad = await this.ensureLease();
|
|
2028
|
+
if (bad) return bad;
|
|
2029
|
+
const id = this.resolve(o.ref);
|
|
2030
|
+
const api = this.api(await this.readToken(id));
|
|
2031
|
+
if (o.md) {
|
|
2032
|
+
const r2 = await api.get(`${id}${id.includes("?") ? "&" : "?"}md`, "text/markdown");
|
|
2033
|
+
return r2.ok ? { text: r2.text.trimEnd(), data: { $id: id }, exit: 0 } : this.fail(r2, `show ${this.label(o.ref, id)}`);
|
|
2034
|
+
}
|
|
2035
|
+
const r = await api.get(id);
|
|
2036
|
+
if (!r.ok) return this.fail(r, `show ${this.label(o.ref, id)}`);
|
|
2037
|
+
const t2 = thingOf(r.json) ?? r.json ?? {};
|
|
2038
|
+
return { text: this.thingText(t2), data: t2, exit: 0 };
|
|
2039
|
+
}
|
|
2040
|
+
/** A Thing in a few lines: `$type name · status`, then its scalar fields, URLs made relative, long values cut. */
|
|
2041
|
+
thingText(t2) {
|
|
2042
|
+
const head = [t2.$type ?? "Thing", t2.name ? `"${t2.name}"` : this.short(t2.$id), t2.status, t2.priorityText ?? (t2.priority !== void 0 ? `p${t2.priority}` : void 0)].filter(Boolean).join(" \xB7 ");
|
|
2043
|
+
const lines = [head];
|
|
2044
|
+
if (t2.name && t2.$id) lines.push(this.short(t2.$id));
|
|
2045
|
+
const c = t2.closesOn;
|
|
2046
|
+
if (c?.event) lines.push(`closes on ${c.event} via ${c.verb}${c.blocked ? ` (blocked: ${c.blocked})` : ""}${c.refused ? ` (refused: ${c.refused})` : ""}${c.approvalNeeded ? " (needs Approval)" : ""}`);
|
|
2047
|
+
if (t2.closedOn) lines.push(`closed on ${typeof t2.closedOn === "string" ? t2.closedOn : JSON.stringify(t2.closedOn)}${t2.closedBy ? ` by ${this.short(t2.closedBy)}` : ""}`);
|
|
2048
|
+
const skip = /* @__PURE__ */ new Set(["$id", "$type", "name", "status", "priority", "priorityText", "priorityWhy", "closesOn", "closedOn", "closedBy", "$context", "api", "links", "nav", "user"]);
|
|
2049
|
+
if (t2.$id) skip.add("id");
|
|
2050
|
+
const shown = /* @__PURE__ */ new Set();
|
|
2051
|
+
for (const [k, v] of Object.entries(t2)) {
|
|
2052
|
+
if (skip.has(k) || v === null || v === void 0 || v === "" || `${k}Text` in t2) continue;
|
|
2053
|
+
const s = this.value(v);
|
|
2054
|
+
if (!s || shown.has(s)) continue;
|
|
2055
|
+
shown.add(s);
|
|
2056
|
+
lines.push(`${k}: ${s}`);
|
|
2057
|
+
if (lines.length >= 24) {
|
|
2058
|
+
lines.push("\u2026 (--json for all)");
|
|
2059
|
+
break;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
return lines.join("\n");
|
|
2063
|
+
}
|
|
2064
|
+
/** one value, compact: URLs relative, objects inline when short, else their size */
|
|
2065
|
+
value(v, max = 100) {
|
|
2066
|
+
if (typeof v === "string") {
|
|
2067
|
+
const s = /^https?:\/\//.test(v) ? this.short(v) : v.replace(/\s+/g, " ");
|
|
2068
|
+
return s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
|
|
2069
|
+
}
|
|
2070
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
2071
|
+
if (Array.isArray(v)) {
|
|
2072
|
+
if (!v.length) return "";
|
|
2073
|
+
const s = v.map((x) => this.value(x, 40)).join(", ");
|
|
2074
|
+
return s.length > max ? `${v.length} items` : s;
|
|
2075
|
+
}
|
|
2076
|
+
if (v && typeof v === "object") {
|
|
2077
|
+
const s = JSON.stringify(v, (_k, x) => typeof x === "string" && x.startsWith(`${this.base}/`) ? this.short(x) : x);
|
|
2078
|
+
return s.length > max ? `{${Object.keys(v).length} keys}` : s;
|
|
2079
|
+
}
|
|
2080
|
+
return "";
|
|
2081
|
+
}
|
|
2082
|
+
// ---- closing ------------------------------------------------------------------------------------------------------
|
|
2083
|
+
/** `field: old → new` for each change a versioned write reports, in whatever of the likely shapes api.sb serves */
|
|
2084
|
+
diffText(a) {
|
|
2085
|
+
if (!a) return "";
|
|
2086
|
+
const raw = a.changed ?? a.changes ?? a.diff ?? a.version?.changes ?? a.version?.diff;
|
|
2087
|
+
const rows = Array.isArray(raw) ? raw.map((x) => ({ field: x.field ?? x.path ?? x.name, from: x.from ?? x.old ?? x.before, to: x.to ?? x.new ?? x.after ?? x.value, verdict: x.verdict })) : raw && typeof raw === "object" ? Object.entries(raw).map(([field, x]) => {
|
|
2088
|
+
const b = x?.from ?? x?.old ?? x?.before;
|
|
2089
|
+
const t2 = x?.to ?? x?.new ?? x?.after;
|
|
2090
|
+
const val = (c) => c && typeof c === "object" && "value" in c ? c.value : c;
|
|
2091
|
+
const same = JSON.stringify(val(b)) === JSON.stringify(val(t2));
|
|
2092
|
+
const mark = t2?.mark;
|
|
2093
|
+
return same && mark ? { field, verdict: `\u2713 ${mark}` } : { field, from: val(b), to: val(t2) };
|
|
2094
|
+
}) : [];
|
|
2095
|
+
const v = a.version === void 0 ? "" : typeof a.version === "object" ? String(a.version.n ?? a.version.version ?? last(a.version.$id)) : String(a.version);
|
|
2096
|
+
const cut = (x) => x === void 0 ? "\u2205" : this.value(x, 40) || "\u2205";
|
|
2097
|
+
const parts = rows.filter((r) => r.field).map((r) => r.verdict && r.verdict !== "corrected" && r.from === void 0 ? `${r.field} ${r.verdict}` : `${r.field}: ${cut(r.from)} \u2192 ${cut(r.to)}`);
|
|
2098
|
+
const shown = parts.slice(0, 6).join("; ") + (parts.length > 6 ? `; +${parts.length - 6}` : "");
|
|
2099
|
+
return [v ? /^v/i.test(v) ? v : `v${v}` : "", shown].filter(Boolean).join(" ");
|
|
2100
|
+
}
|
|
2101
|
+
closeLine(label, r, left) {
|
|
2102
|
+
const a = r.action;
|
|
2103
|
+
const bits = [label];
|
|
2104
|
+
if (r.outcome === "NotSent") return `${label} not sent: ${r.note}`;
|
|
2105
|
+
if (r.outcome === "Blocked" || r.outcome === "KeyReused" || r.outcome === "Error") {
|
|
2106
|
+
return `${label} ${r.outcome} ${r.status}${r.blocked?.code ? ` ${r.blocked.code}` : ""}${r.blocked?.message ? `: ${r.blocked.message}` : ""}`;
|
|
2107
|
+
}
|
|
2108
|
+
bits.push(r.tried ? `try ${r.outcome}` : r.outcome);
|
|
2109
|
+
if (r.outcome === "Refused") {
|
|
2110
|
+
bits.push(`${a?.reason ?? ""}${a?.says ? `: ${a.says}` : ""}`.trim());
|
|
2111
|
+
return bits.filter(Boolean).join(" ");
|
|
2112
|
+
}
|
|
2113
|
+
if (a?.status === "Pending" && a.reason) bits.push(a.reason);
|
|
2114
|
+
bits.push(a?.event ?? r.closesOn.event);
|
|
2115
|
+
const d = this.diffText(a);
|
|
2116
|
+
if (d) bits.push(d);
|
|
2117
|
+
if (r.tried) bits.push("(nothing stored; --do runs it)");
|
|
2118
|
+
if (left !== void 0) bits.push(left ? `\xB7 ${left} left` : "\xB7 batch done");
|
|
2119
|
+
return bits.filter(Boolean).join(" ");
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* `close <ref> [--body]`: invoke the Verb whose Event closes the Task. Inside a claimed batch the default Mode is do
|
|
2123
|
+
* (the claim was the first click) and the lease token acts; elsewhere a try preview with SB_TOKEN or the sign-in.
|
|
2124
|
+
*/
|
|
2125
|
+
async close(o) {
|
|
2126
|
+
const noLease = await this.ensureLease();
|
|
2127
|
+
if (noLease) return noLease;
|
|
2128
|
+
const id = this.resolve(o.ref);
|
|
2129
|
+
const lease = this.leases.holding(id);
|
|
2130
|
+
const mode = o.mode ?? (lease ? "do" : "try");
|
|
2131
|
+
const label = this.label(o.ref, id);
|
|
2132
|
+
let token = lease ? this.leaseCred(lease) : await this.baseToken(true);
|
|
2133
|
+
if (!token) return this.none(`${label}: sign in (startup-builder login) or set SB_TOKEN`);
|
|
2134
|
+
const opts = {
|
|
2135
|
+
task: id,
|
|
2136
|
+
mode,
|
|
2137
|
+
as: o.as,
|
|
2138
|
+
force: o.force,
|
|
2139
|
+
idempotencyKey: o.idempotencyKey,
|
|
2140
|
+
prepare: (c) => {
|
|
2141
|
+
const b = checkBody(c.event, o.body);
|
|
2142
|
+
return b.errors.length ? { error: `body: ${b.errors.join("; ")}` } : { input: b.body };
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
let r;
|
|
2146
|
+
try {
|
|
2147
|
+
r = await closeTask(this.api(token), opts);
|
|
2148
|
+
} catch (e) {
|
|
2149
|
+
const msg = e.message;
|
|
2150
|
+
if (/ is closed \(/.test(msg)) {
|
|
2151
|
+
if (lease) this.leases.markClosed(lease.batch, id);
|
|
2152
|
+
return { text: `${label} already closed${/\(([^)]*)\)/.exec(msg)?.[1] ? ` (${this.short(/\(([^)]*)\)/.exec(msg)[1])})` : ""}`, data: null, exit: 0 };
|
|
2153
|
+
}
|
|
2154
|
+
if (lease && / answered 409: .*lease/.test(msg)) return { text: `${label}: lease ended, stop (${msg.replaceAll(`${this.base}/`, "")})`, data: null, exit: LEASE_ENDED };
|
|
2155
|
+
return { text: `${label}: ${msg.replaceAll(`${this.base}/`, "")}`, data: null, exit: 1 };
|
|
2156
|
+
}
|
|
2157
|
+
if (r.status === 409 && r.blocked?.code === "lease_ended") {
|
|
2158
|
+
if (lease && lease.held !== "dispatcher") this.leases.remove(lease.batch);
|
|
2159
|
+
return { text: `${label}: lease ended, stop${r.blocked.message ? ` (${r.blocked.message})` : ""}`, data: r, exit: LEASE_ENDED };
|
|
2160
|
+
}
|
|
2161
|
+
if (r.status === 401 && !lease && this.d.login) {
|
|
2162
|
+
token = this.cred(await this.d.login());
|
|
2163
|
+
r = await closeTask(this.api(token), { ...opts, idempotencyKey: r.request.idempotencyKey });
|
|
2164
|
+
}
|
|
2165
|
+
let left;
|
|
2166
|
+
let released;
|
|
2167
|
+
const closedNow = !r.tried && (r.outcome === "Done" || r.outcome === "Replayed" && r.action?.status === "Done") && (closesOf(r.action).includes(id) || r.status === 201);
|
|
2168
|
+
if (lease) {
|
|
2169
|
+
const l = closedNow ? this.leases.markClosed(lease.batch, id) ?? lease : lease;
|
|
2170
|
+
left = l.tasks.filter((t2) => !l.closed.includes(t2)).length;
|
|
2171
|
+
if (closedNow && left === 0) released = await this.release({ batch: l });
|
|
2172
|
+
}
|
|
2173
|
+
const text = this.closeLine(label, r, lease && !r.tried ? left : void 0) + (released?.exit ? `
|
|
2174
|
+
${released.text}` : "");
|
|
2175
|
+
const bad = ["Refused", "Blocked", "KeyReused", "Error", "NotSent"].includes(r.outcome);
|
|
2176
|
+
return { text, data: r, exit: bad ? 1 : 0 };
|
|
2177
|
+
}
|
|
2178
|
+
/** the batch's Tasks api.sb shows closed (their page carries `closedOn`, or status Done) */
|
|
2179
|
+
async closedOf(l) {
|
|
2180
|
+
const api = this.api(this.leaseCred(l));
|
|
2181
|
+
const fallback = this.api(await this.baseToken());
|
|
2182
|
+
const out = [];
|
|
2183
|
+
for (const t2 of l.tasks) {
|
|
2184
|
+
let r = await api.get(t2).catch(() => void 0);
|
|
2185
|
+
if (r && (r.status === 401 || r.status === 403)) r = await fallback.get(t2).catch(() => void 0);
|
|
2186
|
+
const th = r?.ok ? thingOf(r.json) : void 0;
|
|
2187
|
+
if (th && (th.closedOn !== void 0 || th.status === "Done")) out.push(t2);
|
|
2188
|
+
}
|
|
2189
|
+
return out;
|
|
2190
|
+
}
|
|
2191
|
+
// ---- the worklist, versions, changes --------------------------------------------------------------------------------
|
|
2192
|
+
worklistText(w) {
|
|
2193
|
+
const lines = [`for:${w.worker.handle} ${w.tasks.length} of ${w.open} open${w.page && w.page > 1 ? ` (page ${w.page})` : ""}`];
|
|
2194
|
+
let n = 0;
|
|
2195
|
+
let head = "";
|
|
2196
|
+
for (const c of w.batch) {
|
|
2197
|
+
const h = `# ${c.startup} ${c.step}`;
|
|
2198
|
+
if (h !== head) lines.push(head = h);
|
|
2199
|
+
for (const t2 of c.tasks) lines.push(this.taskLine(t2, ++n));
|
|
2200
|
+
}
|
|
2201
|
+
if (w.more) lines.push(`${w.more} more: --page ${(w.page ?? 1) + 1}`);
|
|
2202
|
+
return lines.join("\n");
|
|
2203
|
+
}
|
|
2204
|
+
taskLine(t2, n) {
|
|
2205
|
+
const c = t2.closesOn;
|
|
2206
|
+
const flags = [
|
|
2207
|
+
c?.blocked ? `blocked: ${c.blocked}` : "",
|
|
2208
|
+
c?.refused ? `refused: ${c.refused}` : "",
|
|
2209
|
+
c?.approvalNeeded ? "needs Approval" : "",
|
|
2210
|
+
c?.permission && !c.permission.held ? `needs ${Object.keys(c.permission.heldBy ?? {}).join("|") || "a Role"}` : "",
|
|
2211
|
+
t2.overdue ? `${t2.overdue}d overdue` : ""
|
|
2212
|
+
].filter(Boolean);
|
|
2213
|
+
return `${n} p${t2.priority} ${t2.name}${c ? ` \u2192 ${c.event}` : ""}${flags.length ? ` (${flags.join("; ")})` : ""}`;
|
|
2214
|
+
}
|
|
2215
|
+
async worklist(o) {
|
|
2216
|
+
const w = await buildWorklist(this.api(await this.baseToken()), { worker: o.worker, startups: o.startups ?? [], limit: o.limit, page: o.page });
|
|
2217
|
+
this.leases.remember("worklist", w.tasks.map((t2) => t2.$id));
|
|
2218
|
+
return { text: this.worklistText(w), data: w, exit: 0 };
|
|
2219
|
+
}
|
|
2220
|
+
/** `versions <ref>`: a Thing's versions, newest first */
|
|
2221
|
+
async versions(o) {
|
|
2222
|
+
const bad = await this.ensureLease();
|
|
2223
|
+
if (bad) return bad;
|
|
2224
|
+
const id = this.resolve(o.ref);
|
|
2225
|
+
const r = await this.api(await this.readToken(id)).get(`${id}${id.includes("?") ? "&" : "?"}versions`);
|
|
2226
|
+
if (!r.ok) return this.fail(r, `versions ${this.label(o.ref, id)}`);
|
|
2227
|
+
const vs = r.json?.versions ?? r.json?.items ?? [];
|
|
2228
|
+
if (!Array.isArray(vs) || !vs.length) return { text: `${this.label(o.ref, id)}: no versions`, data: r.json, exit: 0 };
|
|
2229
|
+
const lines = vs.slice(0, o.limit ?? 20).map((v) => {
|
|
2230
|
+
const n = v.version ?? v.n ?? last(v.$id);
|
|
2231
|
+
const who = v.by ? ` ${last(v.by)}` : "";
|
|
2232
|
+
const d = this.diffText({ changes: v.changes ?? v.diff ?? v.changed });
|
|
2233
|
+
return `${/^v/i.test(String(n)) ? n : `v${n}`}${v.current ? "*" : ""} ${this.when(v.at ?? v.createdAt ?? v.since)}${who}${d ? ` ${d}` : v.name ? ` ${v.name}` : ""}`;
|
|
2234
|
+
});
|
|
2235
|
+
return { text: lines.join("\n"), data: r.json, exit: 0 };
|
|
2236
|
+
}
|
|
2237
|
+
/** `revert <ref> --to <v>`: POST <$id>:revert {to}; a try preview unless do */
|
|
2238
|
+
async revert(o) {
|
|
2239
|
+
const id = this.resolve(o.ref);
|
|
2240
|
+
const mode = o.mode ?? "try";
|
|
2241
|
+
const token = await this.baseToken(true);
|
|
2242
|
+
if (!token) return this.none("revert: sign in (startup-builder login) or set SB_TOKEN");
|
|
2243
|
+
const to = o.to.replace(/^v(?=\d)/i, "");
|
|
2244
|
+
const r = await this.api(token).post(`${id}:revert`, { to: /^\d+$/.test(to) ? Number(to) : o.to }, { prefer: `mode=${mode}`, idempotencyKey: o.idempotencyKey });
|
|
2245
|
+
const label = this.label(o.ref, id);
|
|
2246
|
+
if (!r.ok && r.status !== 403) return this.fail(r, `revert ${label}`);
|
|
2247
|
+
const a = r.json?.action ?? r.json ?? {};
|
|
2248
|
+
const status = r.status === 403 ? "Refused" : a.status ?? "Done";
|
|
2249
|
+
const d = this.diffText(a);
|
|
2250
|
+
const tail = r.status === 403 ? `${a.reason ?? ""}${a.says ? `: ${a.says}` : ""}` : d;
|
|
2251
|
+
return { text: `revert ${label} to v${to}: ${mode === "try" ? "try " : ""}${status}${tail ? ` ${tail}` : ""}${mode === "try" && r.ok ? " (nothing stored; --do runs it)" : ""}`, data: r.json, exit: r.ok ? 0 : 1 };
|
|
2252
|
+
}
|
|
2253
|
+
/** `changes [--worker w] [--startup s]`: the feed of recent writes */
|
|
2254
|
+
async changes(o = {}) {
|
|
2255
|
+
const q = new URLSearchParams({ limit: String(o.limit ?? 20) });
|
|
2256
|
+
const w = parseWorker(o.worker);
|
|
2257
|
+
if (w) q.set("worker", w);
|
|
2258
|
+
if (o.startup) q.set("startup", o.startup);
|
|
2259
|
+
const r = await this.api(await this.baseToken()).get(`/versions?${q}`);
|
|
2260
|
+
if (!r.ok) return this.fail(r, "changes");
|
|
2261
|
+
const cs = r.json?.versions ?? r.json?.changes ?? r.json?.items ?? [];
|
|
2262
|
+
if (!cs.length) return { text: "no changes", data: r.json, exit: 0 };
|
|
2263
|
+
const lines = cs.map((c) => {
|
|
2264
|
+
const at = this.when(c.at ?? c.createdAt ?? c.since);
|
|
2265
|
+
const who = c.as ?? c.by ?? c.worker ?? c.author;
|
|
2266
|
+
const what = this.short(c.thing ?? c.object ?? c.to ?? c.$id);
|
|
2267
|
+
const d = this.diffText(c);
|
|
2268
|
+
return [at, who ? last(who) : "", what, d || c.name || "", c.status && !d ? `(${c.status})` : ""].filter(Boolean).join(" ");
|
|
2269
|
+
});
|
|
2270
|
+
return { text: lines.join("\n"), data: r.json, exit: 0 };
|
|
2271
|
+
}
|
|
2272
|
+
};
|
|
2273
|
+
|
|
2274
|
+
export {
|
|
2275
|
+
Api,
|
|
2276
|
+
DEFAULT_API,
|
|
2277
|
+
apiBase,
|
|
2278
|
+
tokenFile,
|
|
2279
|
+
runnerBase,
|
|
2280
|
+
pkce,
|
|
2281
|
+
unwrapState,
|
|
2282
|
+
Auth,
|
|
2283
|
+
WorklistError,
|
|
2284
|
+
worklistPath,
|
|
2285
|
+
heldByNames,
|
|
2286
|
+
fetchWorklistMarkdown,
|
|
2287
|
+
parseWorker,
|
|
2288
|
+
derivePriority,
|
|
2289
|
+
stepOf,
|
|
2290
|
+
selectBatch,
|
|
2291
|
+
groupBatch,
|
|
2292
|
+
buildWorklist,
|
|
2293
|
+
findClosesOn,
|
|
2294
|
+
closeTask,
|
|
2295
|
+
closesOf,
|
|
2296
|
+
batchText,
|
|
2297
|
+
closeText,
|
|
2298
|
+
renderPrompt,
|
|
2299
|
+
runCloud,
|
|
2300
|
+
collectionOf,
|
|
2301
|
+
verbPath,
|
|
2302
|
+
parseInput,
|
|
2303
|
+
doVerb,
|
|
2304
|
+
VERDICTS,
|
|
2305
|
+
checkBody,
|
|
2306
|
+
parseBody,
|
|
2307
|
+
batchBodyPath,
|
|
2308
|
+
DEFAULT_MODEL,
|
|
2309
|
+
printRunner,
|
|
2310
|
+
agentRunner,
|
|
2311
|
+
configDir,
|
|
2312
|
+
leasesDir,
|
|
2313
|
+
Leases,
|
|
2314
|
+
DEFAULT_WALL_MINUTES,
|
|
2315
|
+
agentEnv,
|
|
2316
|
+
agentPrompt,
|
|
2317
|
+
work,
|
|
2318
|
+
TOOLS,
|
|
2319
|
+
mcpServer,
|
|
2320
|
+
serveStdio,
|
|
2321
|
+
Session
|
|
2322
|
+
};
|