@phnx-labs/agents-cli 1.22.55 → 1.22.56

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.56
4
+
5
+ - **Scheduled routines claim a durable per-occurrence slot, so a duplicate delivery or a catch-up of the same slot can't double-launch (PHNX-3215).** The forward-timer path keyed its single-fire claim on croner's `currentRun()`, which is the jittered wall-clock fire instant, not the aligned schedule boundary — so two timer callbacks for one occurrence minted different run ids and both launched, and a live fire never collided with its catch-up twin. The scheduler now floors the fire to the aligned `(routine, scheduledFor)` boundary (`fireSlot`/`alignedSlotForFire`), the same derivation catch-up uses, making the atomic slot claim a structural guarantee (SING-15/16, self-overlap SING-13). Source: `cli/src/lib/scheduler.ts`, `cli/src/lib/scheduling/routines.ts`, `cli/src/lib/overdue.ts`.
6
+
7
+ - **Managed-share Open Graph cards render in Cloudflare instead of failing on every new publish (PHNX-2835).** The Worker upload now carries Yoga and resvg as compiled WebAssembly modules alongside the bundled JavaScript and fonts. The previous single-file bundle decoded the WASM into byte arrays and compiled it at request time; Node accepted that in tests, but Cloudflare workerd forbids runtime WASM code generation, so a new share's lazy `<slug>.png` render failed. A real-workerd regression test now renders and validates the PNG, and a genuine renderer failure returns a diagnostic `500` instead of falling through as a missing cover. Source: `cli/src/lib/share/{worker-template,provision}.ts`.
8
+
3
9
  ## 1.22.55
4
10
 
5
11
  - **Managed shares generate their Open Graph cover server-side (PHNX-2835).** Publishing HTML to `share.agents-cli.sh` no longer launches local Chromium. The Worker lazily renders a deterministic 1200×630 AGI card with Satori and resvg-wasm, bundles Inter and JetBrains Mono, inherits the canonical page's visibility gate, and caches the PNG in R2. BYO endpoints keep their local screenshot fallback. An explicit missing `AGENTS_SHARE_BROWSER` or `PUPPETEER_EXECUTABLE_PATH` now fails loudly instead of silently falling through. Source: `cli/src/lib/share/{capture,publish,worker-template}.ts`.
@@ -13,7 +13,7 @@ import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_W
13
13
  import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, hashWorkerScript, putWorkerSecret, updateWorker, WORKER_PHOENIX_ID_BASE_SECRET, setWorkerSecret, } from '../lib/share/provision.js';
14
14
  import { publishFile, resolveShareUsername, parseMetaEntries, sanitizeLabel, resolveShareVisibility, scanShareContent, formatSensitiveContentError, SHARE_VISIBILITY_LEVELS, } from '../lib/share/publish.js';
15
15
  import { deleteShare, resolveDeleteTarget } from '../lib/share/delete.js';
16
- import { renderWorkerScript } from '../lib/share/worker-template.js';
16
+ import { renderWorkerBundle } from '../lib/share/worker-template.js';
17
17
  import { analyticsEnabled } from '../lib/share/analytics.js';
18
18
  import { phoenixIdBaseForDeploy, resolveShareBackend, } from '../lib/share/backend.js';
19
19
  import { resolveGitHubUsername } from '../lib/git.js';
@@ -54,7 +54,7 @@ export function formatSharePublishResult(result, json = false) {
54
54
  export function shareTemplateStatus(cfg) {
55
55
  if (!cfg.templateHash)
56
56
  return 'unknown';
57
- return cfg.templateHash === hashWorkerScript(renderWorkerScript()) ? 'current' : 'outdated';
57
+ return cfg.templateHash === hashWorkerScript(renderWorkerBundle().script) ? 'current' : 'outdated';
58
58
  }
59
59
  export async function runShareEdit(target, opts) {
60
60
  // Same public-listing gate as publish: label + every metadata value are
@@ -1155,7 +1155,8 @@ export async function runShareProvision(opts) {
1155
1155
  const bucketName = opts.bucket;
1156
1156
  const token = generateWriteToken();
1157
1157
  const requestedDomain = cleanHostname(opts.domain) ?? DEFAULT_SHARE_DOMAIN;
1158
- const script = renderWorkerScript();
1158
+ const worker = renderWorkerBundle();
1159
+ const script = worker.script;
1159
1160
  const spin = ora('Provisioning on Cloudflare…').start();
1160
1161
  try {
1161
1162
  const provisionOpts = opts.request ? { request: opts.request } : {};
@@ -1163,7 +1164,7 @@ export async function runShareProvision(opts) {
1163
1164
  spin.text = `R2 bucket '${bucketName}' ready`;
1164
1165
  await configureBucketLifecycle(apiToken, accountId, bucketName, provisionOpts);
1165
1166
  spin.text = `R2 bucket '${bucketName}' lifecycle ready`;
1166
- await deployWorker(apiToken, accountId, workerName, script, bucketName, provisionOpts);
1167
+ await deployWorker(apiToken, accountId, workerName, worker, bucketName, provisionOpts);
1167
1168
  spin.text = `Worker '${workerName}' deployed`;
1168
1169
  await setWorkerSecret(apiToken, accountId, workerName, token, provisionOpts);
1169
1170
  spin.text = `Worker '${workerName}' write token set`;
@@ -1230,14 +1231,14 @@ export async function runShareUpdate(opts = {}) {
1230
1231
  throw new Error("Share endpoint has no Cloudflare account id — `agents artifacts share update` cannot call the API. Pass --account <id>, or re-run 'agents artifacts share join'.");
1231
1232
  }
1232
1233
  const writeToken = readWriteToken();
1233
- const script = renderWorkerScript();
1234
+ const worker = renderWorkerBundle();
1234
1235
  const phoenixIdBase = phoenixIdBaseForDeploy({ managed: opts.managed }, cfg);
1235
1236
  const provisionOpts = {
1236
1237
  ...(opts.request ? { request: opts.request } : {}),
1237
1238
  force: opts.force,
1238
1239
  ...(phoenixIdBase !== undefined ? { phoenixIdBase } : {}),
1239
1240
  };
1240
- const result = await updateWorker(apiToken, accountId, cfg.workerName, cfg.bucketName, script, writeToken, cfg.templateHash, provisionOpts);
1241
+ const result = await updateWorker(apiToken, accountId, cfg.workerName, cfg.bucketName, worker, writeToken, cfg.templateHash, provisionOpts);
1241
1242
  if (!result.skipped) {
1242
1243
  writeShareConfig({ ...cfg, accountId, templateHash: result.templateHash });
1243
1244
  }
@@ -13,48 +13,18 @@
13
13
  */
14
14
  import * as fs from 'fs';
15
15
  import { Cron } from 'croner';
16
- import { listJobs, getLatestRun, resolveJobFilePath, isPastEndAt, isOneShotRoutine, jobRunsOnThisDevice } from './scheduling/routines.js';
16
+ import { alignedSlotForFire, listJobs, getLatestRun, resolveJobFilePath, isPastEndAt, isOneShotRoutine, jobRunsOnThisDevice } from './scheduling/routines.js';
17
17
  import { notifyDesktop } from './menubar/notify-desktop.js';
18
18
  // Tolerance between "expected fire" and "recorded run start" — accounts for
19
19
  // the small gap between the cron tick and when the runner writes meta.json.
20
20
  const GRACE_MS = 60_000;
21
- const DAY_MS = 24 * 60 * 60 * 1000;
22
- /**
23
- * Lookback windows, narrowest first. A fixed one-week window silently blinded
24
- * detection to any cron whose gap exceeds it: `0 9 1,13,25 * *` has 12-day gaps,
25
- * so `nextRun(now - 7d)` jumped past `now`, the walk returned null, and the
26
- * routine was never flagged overdue on any device — no missed record, no
27
- * catch-up, permanently. Monthly, quarterly and annual routines were all in that
28
- * class.
29
- *
30
- * A wider window is only tried when the narrower one found nothing, so a dense
31
- * schedule (every minute, hourly, daily) never walks more than a week of
32
- * occurrences. A sparse schedule has few occurrences to walk by definition.
33
- */
34
- const LOOKBACK_WINDOWS_MS = [7 * DAY_MS, 32 * DAY_MS, 93 * DAY_MS, 400 * DAY_MS];
35
- /** Compute the most recent fire of `pattern` at or before `now`. Croner's
36
- * `previousRun()` returns the cron instance's own last fire, which is null
37
- * on a freshly-constructed instance — so we walk `nextRun(cursor)` forward
38
- * from a week ago and keep the last fire still ≤ now. */
21
+ /** Compute the most recent fire of `pattern` at or before `now`. Delegates to
22
+ * {@link alignedSlotForFire} so overdue detection (`missedRunId`) and the live
23
+ * forward-timer dispatch (`slotRunId`) key on the SAME occurrence identity — a
24
+ * missed fire and its live twin for one UTC slot then collide by construction
25
+ * (SING-15). */
39
26
  function previousExpectedFire(cron, now) {
40
- for (const window of LOOKBACK_WINDOWS_MS) {
41
- let cursor = new Date(now.getTime() - window);
42
- let last = null;
43
- // Cap iterations: an every-minute schedule yields ≤ 10080 steps over a week;
44
- // 20k is a paranoia bound against pathological patterns. Only a schedule
45
- // that found nothing in the narrower window reaches a wider one, and such a
46
- // schedule is sparse, so the cap is never the binding constraint.
47
- for (let i = 0; i < 20000; i++) {
48
- const next = cron.nextRun(cursor);
49
- if (!next || next.getTime() > now.getTime())
50
- break;
51
- last = next;
52
- cursor = next;
53
- }
54
- if (last)
55
- return last;
56
- }
57
- return null;
27
+ return alignedSlotForFire(cron, now);
58
28
  }
59
29
  /**
60
30
  * When a routine started existing, and therefore the earliest fire it can
@@ -5,13 +5,32 @@
5
5
  * process creates a single JobScheduler instance that loads enabled jobs
6
6
  * on startup and reloads them on SIGHUP.
7
7
  */
8
+ import { Cron } from 'croner';
8
9
  import type { JobConfig } from './scheduling/routines.js';
9
10
  /** How a fire was triggered, carrying the scheduler's intended UTC slot time. */
10
11
  export interface TriggerContext {
11
- /** The cron slot this callback fires for (croner `currentRun()`), for the
12
- * single-fire claim keyed on (routine, scheduledFor). */
12
+ /** The ALIGNED cron slot this callback fires for, for the single-fire claim
13
+ * keyed on (routine, scheduledFor). Derived by {@link fireSlot} — NOT croner's
14
+ * raw `currentRun()`, which carries wall-clock jitter. */
13
15
  scheduledFor?: Date;
14
16
  }
17
+ /**
18
+ * The aligned occurrence boundary a fire callback belongs to — the value the
19
+ * single-fire `(routine, scheduledFor)` claim keys on.
20
+ *
21
+ * croner's `currentRun()` inside a fire callback is the JITTERED wall-clock
22
+ * trigger instant (it carries milliseconds — verified against croner 10.x), not
23
+ * the aligned schedule boundary. Keying `slotRunId` on it directly minted a
24
+ * distinct run id per delivery, so two callbacks for one occurrence each claimed
25
+ * a different run dir and both launched, and a live fire never collided with its
26
+ * catch-up twin (`missedRunId`, which keys on the aligned boundary). Flooring the
27
+ * fire to its schedule boundary via {@link alignedSlotForFire} makes the claim a
28
+ * structural claim on the occurrence identity (SING-15). Always returns a
29
+ * concrete Date — `currentRun()` falls back to now, and an unresolvable boundary
30
+ * falls back to the fire instant — so the forward path never dispatches without a
31
+ * durable slot key.
32
+ */
33
+ export declare function fireSlot(cron: Cron): Date;
15
34
  /** In-memory cron scheduler that triggers a callback when jobs fire. */
16
35
  export declare class JobScheduler {
17
36
  private jobs;
@@ -6,7 +6,27 @@
6
6
  * on startup and reloads them on SIGHUP.
7
7
  */
8
8
  import { Cron } from 'croner';
9
- import { listJobs, deleteJob, isPastEndAt, isPastOneShotRoutine, isOneShotRoutine, setJobEnabled, shouldPurgeCompletedOneShotRoutine, jobRunsOnThisDevice, hasAmbiguousDevicePin, routineOwnerDevice, } from './scheduling/routines.js';
9
+ import { alignedSlotForFire, listJobs, deleteJob, isPastEndAt, isPastOneShotRoutine, isOneShotRoutine, setJobEnabled, shouldPurgeCompletedOneShotRoutine, jobRunsOnThisDevice, hasAmbiguousDevicePin, routineOwnerDevice, } from './scheduling/routines.js';
10
+ /**
11
+ * The aligned occurrence boundary a fire callback belongs to — the value the
12
+ * single-fire `(routine, scheduledFor)` claim keys on.
13
+ *
14
+ * croner's `currentRun()` inside a fire callback is the JITTERED wall-clock
15
+ * trigger instant (it carries milliseconds — verified against croner 10.x), not
16
+ * the aligned schedule boundary. Keying `slotRunId` on it directly minted a
17
+ * distinct run id per delivery, so two callbacks for one occurrence each claimed
18
+ * a different run dir and both launched, and a live fire never collided with its
19
+ * catch-up twin (`missedRunId`, which keys on the aligned boundary). Flooring the
20
+ * fire to its schedule boundary via {@link alignedSlotForFire} makes the claim a
21
+ * structural claim on the occurrence identity (SING-15). Always returns a
22
+ * concrete Date — `currentRun()` falls back to now, and an unresolvable boundary
23
+ * falls back to the fire instant — so the forward path never dispatches without a
24
+ * durable slot key.
25
+ */
26
+ export function fireSlot(cron) {
27
+ const fire = cron.currentRun() ?? new Date();
28
+ return alignedSlotForFire(cron, fire) ?? fire;
29
+ }
10
30
  /** In-memory cron scheduler that triggers a callback when jobs fire. */
11
31
  export class JobScheduler {
12
32
  jobs = new Map();
@@ -75,10 +95,13 @@ export class JobScheduler {
75
95
  return;
76
96
  }
77
97
  try {
78
- // croner hands the callback its own Cron instance; `currentRun()` is the
79
- // UTC time THIS invocation was scheduled for — the single-fire slot key.
80
- // A duplicate delivery for the same slot resolves to one run downstream.
81
- await this.onTrigger(config, { scheduledFor: self.currentRun() ?? undefined });
98
+ // scheduledFor is the ALIGNED occurrence boundary (fireSlot), not croner's
99
+ // jittered currentRun(): the single-fire claim keys on (routine,
100
+ // scheduledFor), so the key must be the occurrence identity or a live fire
101
+ // and its catch-up twin (missedRunId) won't collide and two deliveries of
102
+ // one slot each mint a distinct id. fireSlot always returns a Date, so the
103
+ // forward path always carries a durable claim (SING-15).
104
+ await this.onTrigger(config, { scheduledFor: fireSlot(self) });
82
105
  }
83
106
  catch (err) {
84
107
  console.error(`Job '${config.name}' failed:`, err.message);
@@ -6,6 +6,7 @@
6
6
  * run metadata persistence, prompt variable expansion, and one-shot "at" time
7
7
  * scheduling.
8
8
  */
9
+ import { Cron } from 'croner';
9
10
  import { type ResolvedExecutionContext, type ProjectResolution, type PlacementMode, type RoutineKind, type ContextFsProbe } from '../routine-context.js';
10
11
  import type { AgentId, RunStrategy } from '../types.js';
11
12
  import type { LoopConfig } from '../loop.js';
@@ -837,6 +838,26 @@ export declare function getRunDir(jobName: string, runId: string): string;
837
838
  * for the same UTC slot are one record.
838
839
  */
839
840
  export declare function slotRunId(scheduledFor: Date | string): string;
841
+ /**
842
+ * The aligned schedule boundary a fire belongs to: the most recent occurrence of
843
+ * `cron` at or before `at`.
844
+ *
845
+ * This is the occurrence IDENTITY that {@link slotRunId} (forward dispatch) and
846
+ * `missedRunId` (catchup.ts) must both key on. croner's `currentRun()` inside a
847
+ * fire callback is the JITTERED wall-clock trigger instant (it carries
848
+ * milliseconds — verified), not the aligned boundary, so keying `slotRunId`
849
+ * directly on it produced a distinct id per delivery: two callbacks for one
850
+ * occurrence each claimed a different run dir and both launched, and a live fire
851
+ * never collided with its catch-up twin (which keys on the aligned
852
+ * `previousExpectedFire`). Flooring both to this boundary is what makes the
853
+ * single-fire claim a structural claim on `(routine, scheduledFor)` (SING-15).
854
+ *
855
+ * croner's `previousRun()` takes no argument and returns null on a freshly
856
+ * constructed instance, so we walk `nextRun(cursor)` forward from a lookback
857
+ * window and keep the last fire still ≤ `at` — the same derivation catchup's
858
+ * overdue detection has always used.
859
+ */
860
+ export declare function alignedSlotForFire(cron: Cron, at: Date): Date | null;
840
861
  /**
841
862
  * Atomically CLAIM a run directory. Returns true on a successful claim, false
842
863
  * when the directory already exists (another caller — even in a separate process
@@ -1518,6 +1518,62 @@ export function slotRunId(scheduledFor) {
1518
1518
  const iso = typeof scheduledFor === 'string' ? scheduledFor : scheduledFor.toISOString();
1519
1519
  return iso.replace(/[:.]/g, '-');
1520
1520
  }
1521
+ /**
1522
+ * Lookback windows for {@link alignedSlotForFire}, narrowest first. A wider
1523
+ * window is tried ONLY when the narrower one found no fire, so:
1524
+ * - a dense schedule (every-minute) resolves in the 1-hour window — ~60 steps,
1525
+ * not ~10080 — which matters because the forward-timer path now runs this on
1526
+ * every fire (a live fire is milliseconds past its boundary, so the narrowest
1527
+ * window always contains it);
1528
+ * - a sparse schedule (`0 9 1,13,25 * *` has 12-day gaps; monthly/quarterly/
1529
+ * annual) still resolves, because a fixed short window silently blinded
1530
+ * overdue detection to any cron whose gap exceeded it.
1531
+ * A narrower window can only ever find the true most-recent fire ≤ `at` or
1532
+ * nothing (never a wrong boundary), so prepending the cheap windows is
1533
+ * behavior-preserving for the sparse-schedule overdue path.
1534
+ */
1535
+ const HOUR_MS = 60 * 60 * 1000;
1536
+ const DAY_MS = 24 * HOUR_MS;
1537
+ const SLOT_LOOKBACK_WINDOWS_MS = [HOUR_MS, DAY_MS, 7 * DAY_MS, 32 * DAY_MS, 93 * DAY_MS, 400 * DAY_MS];
1538
+ /**
1539
+ * The aligned schedule boundary a fire belongs to: the most recent occurrence of
1540
+ * `cron` at or before `at`.
1541
+ *
1542
+ * This is the occurrence IDENTITY that {@link slotRunId} (forward dispatch) and
1543
+ * `missedRunId` (catchup.ts) must both key on. croner's `currentRun()` inside a
1544
+ * fire callback is the JITTERED wall-clock trigger instant (it carries
1545
+ * milliseconds — verified), not the aligned boundary, so keying `slotRunId`
1546
+ * directly on it produced a distinct id per delivery: two callbacks for one
1547
+ * occurrence each claimed a different run dir and both launched, and a live fire
1548
+ * never collided with its catch-up twin (which keys on the aligned
1549
+ * `previousExpectedFire`). Flooring both to this boundary is what makes the
1550
+ * single-fire claim a structural claim on `(routine, scheduledFor)` (SING-15).
1551
+ *
1552
+ * croner's `previousRun()` takes no argument and returns null on a freshly
1553
+ * constructed instance, so we walk `nextRun(cursor)` forward from a lookback
1554
+ * window and keep the last fire still ≤ `at` — the same derivation catchup's
1555
+ * overdue detection has always used.
1556
+ */
1557
+ export function alignedSlotForFire(cron, at) {
1558
+ for (const window of SLOT_LOOKBACK_WINDOWS_MS) {
1559
+ let cursor = new Date(at.getTime() - window);
1560
+ let last = null;
1561
+ // Cap iterations: an every-minute schedule yields ≤ 10080 steps over a week;
1562
+ // 20k is a paranoia bound against pathological patterns. Only a schedule that
1563
+ // found nothing in the narrower window reaches a wider one, and such a
1564
+ // schedule is sparse, so the cap is never the binding constraint.
1565
+ for (let i = 0; i < 20000; i++) {
1566
+ const next = cron.nextRun(cursor);
1567
+ if (!next || next.getTime() > at.getTime())
1568
+ break;
1569
+ last = next;
1570
+ cursor = next;
1571
+ }
1572
+ if (last)
1573
+ return last;
1574
+ }
1575
+ return null;
1576
+ }
1521
1577
  /**
1522
1578
  * Atomically CLAIM a run directory. Returns true on a successful claim, false
1523
1579
  * when the directory already exists (another caller — even in a separate process
@@ -1,3 +1,4 @@
1
+ import type { WorkerBundle } from './worker-template.js';
1
2
  export declare const SHARE_LIFECYCLE_RULE_ID = "agents-share-expire-objects";
2
3
  export declare const SHARE_LIFECYCLE_RETENTION_DAYS = 366;
3
4
  interface R2LifecycleRule {
@@ -50,7 +51,7 @@ export declare function mergeShareLifecycleRule(existing?: R2LifecycleRule[], ru
50
51
  /** Ensure the share bucket self-cleans old objects. Exact per-link expiry is enforced by the Worker. */
51
52
  export declare function configureBucketLifecycle(apiToken: string, accountId: string, bucketName: string, opts?: ProvisionOptions): Promise<void>;
52
53
  /** Upload the module Worker with an R2 binding (`BUCKET`). Secrets are set via the Workers Secrets API. */
53
- export declare function deployWorker(apiToken: string, accountId: string, workerName: string, script: string, bucketName: string, opts?: ProvisionOptions): Promise<void>;
54
+ export declare function deployWorker(apiToken: string, accountId: string, workerName: string, worker: string | WorkerBundle, bucketName: string, opts?: ProvisionOptions): Promise<void>;
54
55
  /** sha256 of the rendered Worker script, so a deployed endpoint can be compared
55
56
  * against the current `worker-template.ts` without redeploying to find out. */
56
57
  export declare function hashWorkerScript(script: string): string;
@@ -100,7 +101,7 @@ export type UpdateWorkerOpts = ProvisionOptions & {
100
101
  * re-setting the secret to its own value after every deploy gets the same
101
102
  * outcome without depending on an unverified upload-time flag.
102
103
  */
103
- export declare function updateWorker(apiToken: string, accountId: string, workerName: string, bucketName: string, script: string, writeToken: string, previousHash: string | undefined, opts?: UpdateWorkerOpts): Promise<UpdateWorkerResult>;
104
+ export declare function updateWorker(apiToken: string, accountId: string, workerName: string, bucketName: string, worker: string | WorkerBundle, writeToken: string, previousHash: string | undefined, opts?: UpdateWorkerOpts): Promise<UpdateWorkerResult>;
104
105
  /** Add/update a secret_text binding using Cloudflare's Workers Secrets API. */
105
106
  export declare function putWorkerSecret(apiToken: string, accountId: string, workerName: string, name: string, text: string, opts?: ProvisionOptions): Promise<void>;
106
107
  /** Add/update the WRITE_TOKEN binding using Cloudflare's Workers Secrets API. */
@@ -77,7 +77,7 @@ export async function configureBucketLifecycle(apiToken, accountId, bucketName,
77
77
  });
78
78
  }
79
79
  /** Upload the module Worker with an R2 binding (`BUCKET`). Secrets are set via the Workers Secrets API. */
80
- export async function deployWorker(apiToken, accountId, workerName, script, bucketName, opts = {}) {
80
+ export async function deployWorker(apiToken, accountId, workerName, worker, bucketName, opts = {}) {
81
81
  const request = opts.request ?? defaultCloudflareRequester;
82
82
  const metadata = {
83
83
  main_module: 'worker.js',
@@ -88,7 +88,11 @@ export async function deployWorker(apiToken, accountId, workerName, script, buck
88
88
  };
89
89
  const form = new FormData();
90
90
  form.set('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
91
- form.set('worker.js', new Blob([script], { type: 'application/javascript+module' }), 'worker.js');
91
+ const bundle = typeof worker === 'string' ? { script: worker, modules: [] } : worker;
92
+ form.set('worker.js', new Blob([bundle.script], { type: 'application/javascript+module' }), 'worker.js');
93
+ for (const module of bundle.modules) {
94
+ form.set(module.name, new Blob([module.contents], { type: module.contentType }), module.name);
95
+ }
92
96
  await request({
93
97
  apiToken,
94
98
  method: 'PUT',
@@ -130,7 +134,8 @@ export const WORKER_PHOENIX_ID_BASE_SECRET = 'PHOENIX_ID_BASE';
130
134
  * re-setting the secret to its own value after every deploy gets the same
131
135
  * outcome without depending on an unverified upload-time flag.
132
136
  */
133
- export async function updateWorker(apiToken, accountId, workerName, bucketName, script, writeToken, previousHash, opts = {}) {
137
+ export async function updateWorker(apiToken, accountId, workerName, bucketName, worker, writeToken, previousHash, opts = {}) {
138
+ const script = typeof worker === 'string' ? worker : worker.script;
134
139
  const templateHash = hashWorkerScript(script);
135
140
  if (!opts.force && previousHash === templateHash) {
136
141
  // Script is current so we skip the upload (which would wipe secrets). A
@@ -142,7 +147,7 @@ export async function updateWorker(apiToken, accountId, workerName, bucketName,
142
147
  }
143
148
  return { templateHash, skipped: true };
144
149
  }
145
- await deployWorker(apiToken, accountId, workerName, script, bucketName, opts);
150
+ await deployWorker(apiToken, accountId, workerName, worker, bucketName, opts);
146
151
  // Script upload clears bindings/secrets (see JSDoc above). If re-applying
147
152
  // WRITE_TOKEN fails here, the live Worker has no write token — every
148
153
  // `agents artifacts share` publish/delete 401s until a re-run of `agents artifacts share update`
@@ -1,4 +1,27 @@
1
- /** Bundle the Worker and its renderer into one uploadable ES module. */
1
+ /**
2
+ * Render the Worker source. Pure — the R2 binding + token are wired at deploy time.
3
+ *
4
+ * The literal below still spells the CLI `agents share` in its provenance comment,
5
+ * its root response, and its gallery title, even though the command is now
6
+ * `agents artifacts share` (RUSH-2580). That is deliberate: `hashWorkerScript` of
7
+ * this exact text is what `shareTemplateStatus` compares a provisioned endpoint's
8
+ * recorded `templateHash` against, so editing ANY byte here marks every already-
9
+ * deployed endpoint `outdated` — which makes `agents artifacts share list` refuse
10
+ * until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
11
+ * worth that; change this text only alongside a real Worker behavior change.
12
+ */
13
+ export interface WorkerModule {
14
+ name: string;
15
+ contentType: 'application/wasm';
16
+ contents: Uint8Array<ArrayBuffer>;
17
+ }
18
+ export interface WorkerBundle {
19
+ script: string;
20
+ modules: WorkerModule[];
21
+ }
22
+ /** Bundle the Worker renderer into an ES module plus workerd-compiled WASM modules. */
23
+ export declare function renderWorkerBundle(): WorkerBundle;
24
+ /** Single-file representation for direct Node tests, which cannot import compiled WASM modules. */
2
25
  export declare function renderWorkerScript(): string;
3
26
  /** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
4
27
  export declare function renderWorkerSource(): string;
@@ -1,81 +1,53 @@
1
1
  import { buildSync } from 'esbuild';
2
2
  import { dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- // The Cloudflare Worker that fronts the R2 share bucket.
5
- //
6
- // One tiny Worker does both sides:
7
- // - PUT /<username>/<slug> — write-gated by authorizeWrite's THREE principals
8
- // (not a fallback chain — each is a distinct legitimate identity):
9
- // 1. Static WRITE_TOKEN (BYO Cloudflare) — checked first when the presented
10
- // bearer equals env.WRITE_TOKEN. owner = env.SHARE_NAMESPACE or the
11
- // path's first segment.
12
- // 2. Phoenix bearer — otherwise GET ${env.PHOENIX_ID_BASE}/api/v1/auth/me
13
- // with that bearer → {userId,email}; 401 if absent/invalid.
14
- // customMetadata.owner = userId (stable). The path's first segment MUST
15
- // equal handleFromEmail(email) (the public handle, e.g. muqsitnawaz),
16
- // so one user cannot write another's prefix (403 namespace mismatch).
17
- // A __handles/<handle> claim binds the handle to the first userId that
18
- // writes it; a different userId gets 409 handle taken.
19
- // 3. __share HMAC cookie — the signed-in viewer's identity cookie (same
20
- // {userId,email} identityFromCookie verifies for GET). Lets the shared
21
- // page's inline visibility control PATCH with credentials:'include' and
22
- // no bearer; SameSite=Lax blocks it cross-site, and the same namespace/
23
- // owner checks confine it to the holder's own pages. Applies to PUT,
24
- // PATCH, and DELETE alike, since all three share authorizeWrite.
25
- // A managed deployment sets PHOENIX_ID_BASE; BYO sets WRITE_TOKEN; the
26
- // platform endpoint may set both. Fail loud (401) when none authenticates.
27
- // Writes the body to R2 via the BUCKET binding, storing visibility
28
- // (public|unlisted|me|org), owner, org_domain (org only), an optional
29
- // expires-at, plus provenance (agent/session/host/repo/date), a label, and
30
- // any `--meta` entries in object metadata. me/org require a Phoenix
31
- // identity (BYO WRITE_TOKEN cannot publish them). org from a public inbox
32
- // domain is 400. Overwriting an existing slug first copies the current
33
- // object to <slug>/rev-<ts>-<rand> (revision history) unless
34
- // x-share-no-revision is set.
35
- // - PATCH /<username>/<slug> — authenticated metadata-only edit. Rewrites the
36
- // exact existing body with all HTTP/custom metadata preserved except the
37
- // explicitly requested label/arbitrary metadata changes; never revisions.
38
- // Conditional put (onlyIf etagMatches) so a concurrent republish 409s
39
- // instead of rolling the body back. Phoenix requires customMetadata.owner
40
- // === auth.owner (fail closed when the stamp is missing); WRITE_TOKEN is
41
- // the admin repair path.
42
- // - GET /<username>/<slug> — public|unlisted are anonymous; me requires the
43
- // Phoenix owner, org requires a same-domain Phoenix identity (Bearer, then
44
- // HMAC cookie, then phoenix_ticket). Unauthenticated me/org 302s to
45
- // Phoenix login (or 401 JSON if PHOENIX_ID_BASE is unset). 410s (and lazily
46
- // deletes) once its expiry has passed. A bucket lifecycle rule is the durable
47
- // sweeper; this is the immediate gate.
48
- // - GET /<username>/<slug>?revisions=json — machine-readable history of the
49
- // retained prior versions under that slug, newest first.
50
- // - GET /<username> — public gallery of that user's shares (HTML).
51
- // - GET /<username>?format=json — public machine-readable listing of that user's
52
- // ACTIVE shares (`agents artifacts share list`). Same single-segment path as the HTML
53
- // gallery and gated on the SAME "does <username>/ hold any object" check, so it
54
- // only intercepts a genuine namespace — a legacy flat slug with ?format=json
55
- // still serves its real content, never a fake empty listing.
56
- // - GET /<slug> — backward-compat flat slug (legacy shares before
57
- // per-user namespaces).
58
- //
59
- // Emitted as a string, so it compiles into `dist/**` and ships with no
60
- // package.json#files change. `provision.ts` uploads
61
- // this verbatim as an ES-module Worker with a BUCKET (R2) binding + a WRITE_TOKEN secret.
62
- /**
63
- * Render the Worker source. Pure — the R2 binding + token are wired at deploy time.
64
- *
65
- * The literal below still spells the CLI `agents share` in its provenance comment,
66
- * its root response, and its gallery title, even though the command is now
67
- * `agents artifacts share` (RUSH-2580). That is deliberate: `hashWorkerScript` of
68
- * this exact text is what `shareTemplateStatus` compares a provisioned endpoint's
69
- * recorded `templateHash` against, so editing ANY byte here marks every already-
70
- * deployed endpoint `outdated` — which makes `agents artifacts share list` refuse
71
- * until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
72
- * worth that; change this text only alongside a real Worker behavior change.
73
- */
74
- let bundledWorkerScript;
75
- /** Bundle the Worker and its renderer into one uploadable ES module. */
4
+ let bundledWorker;
5
+ let nodeWorkerScript;
6
+ /** Bundle the Worker renderer into an ES module plus workerd-compiled WASM modules. */
7
+ export function renderWorkerBundle() {
8
+ if (bundledWorker)
9
+ return bundledWorker;
10
+ const result = buildSync({
11
+ stdin: {
12
+ contents: renderWorkerSource(),
13
+ loader: 'js',
14
+ resolveDir: dirname(fileURLToPath(import.meta.url)),
15
+ sourcefile: 'agents-share-worker.js',
16
+ },
17
+ bundle: true,
18
+ format: 'esm',
19
+ platform: 'browser',
20
+ target: 'es2022',
21
+ write: false,
22
+ outdir: 'worker-bundle',
23
+ assetNames: '[name]-[hash]',
24
+ minify: true,
25
+ // Fonts are plain data and can live in JavaScript. WASM must remain a
26
+ // compiled module: workerd deliberately forbids runtime code generation,
27
+ // so esbuild's `binary` loader produces a bundle that works in Node but
28
+ // throws "Wasm code generation disallowed by embedder" in Cloudflare.
29
+ loader: { '.wasm': 'copy', '.woff': 'binary' },
30
+ });
31
+ const script = result.outputFiles.find((output) => output.path.endsWith('.js'));
32
+ if (!script)
33
+ throw new Error('Worker bundling produced no JavaScript output.');
34
+ const modules = result.outputFiles
35
+ .filter((output) => output.path.endsWith('.wasm'))
36
+ .map((output) => ({
37
+ name: output.path.split('/').pop(),
38
+ contentType: 'application/wasm',
39
+ contents: new Uint8Array(output.contents),
40
+ }));
41
+ if (modules.length !== 2) {
42
+ throw new Error(`Worker bundling produced ${modules.length} WASM modules; expected yoga and resvg.`);
43
+ }
44
+ bundledWorker = { script: script.text, modules };
45
+ return bundledWorker;
46
+ }
47
+ /** Single-file representation for direct Node tests, which cannot import compiled WASM modules. */
76
48
  export function renderWorkerScript() {
77
- if (bundledWorkerScript)
78
- return bundledWorkerScript;
49
+ if (nodeWorkerScript)
50
+ return nodeWorkerScript;
79
51
  const result = buildSync({
80
52
  stdin: {
81
53
  contents: renderWorkerSource(),
@@ -93,9 +65,9 @@ export function renderWorkerScript() {
93
65
  });
94
66
  const output = result.outputFiles[0];
95
67
  if (!output)
96
- throw new Error('Worker bundling produced no JavaScript output.');
97
- bundledWorkerScript = output.text;
98
- return bundledWorkerScript;
68
+ throw new Error('Worker test bundling produced no JavaScript output.');
69
+ nodeWorkerScript = output.text;
70
+ return nodeWorkerScript;
99
71
  }
100
72
  /** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
101
73
  export function renderWorkerSource() {
@@ -491,13 +463,22 @@ export default {
491
463
 
492
464
  const pageHtml = await page.text();
493
465
  const meta = page.customMetadata || {};
494
- const png = await hooks.renderOgCard({
495
- title: meta['og-title'] || extractHtmlMeta(pageHtml, 'title') || meta.label || segments[1],
496
- description: meta['og-description'] || extractHtmlMeta(pageHtml, 'description') || '',
497
- handle: segments[0],
498
- visibility: pageVisibility,
499
- orgDomain: meta.org_domain || '',
500
- });
466
+ let png;
467
+ try {
468
+ png = await hooks.renderOgCard({
469
+ title: meta['og-title'] || extractHtmlMeta(pageHtml, 'title') || meta.label || segments[1],
470
+ description: meta['og-description'] || extractHtmlMeta(pageHtml, 'description') || '',
471
+ handle: segments[0],
472
+ visibility: pageVisibility,
473
+ orgDomain: meta.org_domain || '',
474
+ });
475
+ } catch (error) {
476
+ const detail = error instanceof Error ? error.message : String(error);
477
+ return new Response('OG card render failed: ' + detail, {
478
+ status: 500,
479
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
480
+ });
481
+ }
501
482
  const beforeStore = await env.BUCKET.get(pagePath);
502
483
  if (!beforeStore || beforeStore.etag !== page.etag) { existingCover = null; continue; }
503
484
  await env.BUCKET.put(path, png, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.55",
3
+ "version": "1.22.56",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",