@phnx-labs/agents-cli 1.22.49 → 1.22.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +10 -0
  3. package/dist/commands/accounts.js +1 -13
  4. package/dist/commands/daemon-test-harness.d.ts +42 -0
  5. package/dist/commands/daemon-test-harness.js +90 -0
  6. package/dist/commands/device-config-test-harness.d.ts +11 -0
  7. package/dist/commands/device-config-test-harness.js +59 -0
  8. package/dist/commands/exec.js +24 -1
  9. package/dist/commands/share.js +9 -4
  10. package/dist/commands/teams.d.ts +12 -0
  11. package/dist/commands/teams.js +44 -67
  12. package/dist/commands/traces.js +4 -0
  13. package/dist/lib/account-provider-registry.d.ts +9 -0
  14. package/dist/lib/account-provider-registry.js +20 -0
  15. package/dist/lib/accounting/account-pool-collect.d.ts +34 -0
  16. package/dist/lib/accounting/account-pool-collect.js +71 -0
  17. package/dist/lib/accounting/account-pool.d.ts +46 -0
  18. package/dist/lib/accounting/account-pool.js +33 -0
  19. package/dist/lib/accounting/capacity.d.ts +24 -0
  20. package/dist/lib/accounting/capacity.js +30 -0
  21. package/dist/lib/accounting/rotate.d.ts +11 -18
  22. package/dist/lib/accounting/rotate.js +6 -24
  23. package/dist/lib/installations/store.js +6 -3
  24. package/dist/lib/installations/versions.js +48 -46
  25. package/dist/lib/share/publish.d.ts +8 -3
  26. package/dist/lib/share/publish.js +6 -3
  27. package/dist/lib/share/worker-template.js +239 -22
  28. package/dist/lib/teams/agents.d.ts +14 -12
  29. package/dist/lib/teams/agents.js +188 -73
  30. package/dist/lib/teams/api.d.ts +6 -1
  31. package/dist/lib/teams/api.js +2 -0
  32. package/dist/lib/teams/scheduler.d.ts +7 -0
  33. package/dist/lib/teams/scheduler.js +26 -8
  34. package/dist/lib/traces/sync.js +12 -0
  35. package/package.json +1 -1
@@ -21,7 +21,9 @@ export interface PublishOptions {
21
21
  /**
22
22
  * Server-enforced visibility (RUSH-3135). `public` (default) is listed in
23
23
  * the gallery; `unlisted` is a capability URL (GET still 200, X-Robots-Tag:
24
- * noindex, hidden from gallery/list). `org` is Phase 2 — the Worker 400s it.
24
+ * noindex, hidden from gallery/list). `me` requires a Phoenix session and is
25
+ * visible only to the signed-in owner; `org` requires the same and is visible
26
+ * to members of the same Phoenix organization.
25
27
  */
26
28
  visibility?: ShareVisibility;
27
29
  /**
@@ -80,7 +82,7 @@ export interface PublishOptions {
80
82
  /** DI seam for tests — override provenance auto-capture (agent/session/host/repo/date). */
81
83
  provenance?: ShareProvenance;
82
84
  }
83
- export type ShareVisibility = 'public' | 'unlisted';
85
+ export type ShareVisibility = 'public' | 'unlisted' | 'me' | 'org';
84
86
  export interface PublishResult {
85
87
  url: string;
86
88
  expiresAt?: string;
@@ -94,7 +96,10 @@ export interface PublishResult {
94
96
  /** Whether `label` came from `--label` or was auto-derived. */
95
97
  labelSource?: 'explicit' | 'derived';
96
98
  }
97
- /** `--unlisted` / `{ unlisted: true }` map to `unlisted`; otherwise `visibility` (default public). */
99
+ /**
100
+ * `--unlisted` / `{ unlisted: true }` map to `unlisted`; `--visibility me|org`
101
+ * passes through; otherwise `visibility` (default public).
102
+ */
98
103
  export declare function resolveShareVisibility(opts?: {
99
104
  visibility?: ShareVisibility;
100
105
  unlisted?: boolean;
@@ -17,12 +17,15 @@ import { captureCover, OG_WIDTH, OG_HEIGHT, OG_SCALE } from './capture.js';
17
17
  import { deriveMeta, injectOgMeta } from './og.js';
18
18
  import { injectAnalyticsBeacon } from './analytics.js';
19
19
  import { prepareShareHtml } from './html.js';
20
- /** `--unlisted` / `{ unlisted: true }` map to `unlisted`; otherwise `visibility` (default public). */
20
+ /**
21
+ * `--unlisted` / `{ unlisted: true }` map to `unlisted`; `--visibility me|org`
22
+ * passes through; otherwise `visibility` (default public).
23
+ */
21
24
  export function resolveShareVisibility(opts = {}) {
22
25
  if (opts.unlisted === true)
23
26
  return 'unlisted';
24
- if (opts.visibility === 'unlisted')
25
- return 'unlisted';
27
+ if (opts.visibility === 'unlisted' || opts.visibility === 'me' || opts.visibility === 'org')
28
+ return opts.visibility;
26
29
  return 'public';
27
30
  }
28
31
  /**
@@ -15,12 +15,17 @@
15
15
  // A managed deployment sets PHOENIX_ID_BASE; BYO sets WRITE_TOKEN; the
16
16
  // platform endpoint may set both. Fail loud (401) when neither authenticates.
17
17
  // Writes the body to R2 via the BUCKET binding, storing visibility
18
- // (public|unlisted; org → 400 in P1), owner, an optional expires-at, plus
19
- // provenance (agent/session/host/repo/date), a label, and any `--meta`
20
- // entries in object metadata. Overwriting an existing slug first copies the
21
- // current object to <slug>/rev-<ts>-<rand> (revision history) unless
18
+ // (public|unlisted|me|org), owner, org_domain (org only), an optional
19
+ // expires-at, plus provenance (agent/session/host/repo/date), a label, and
20
+ // any `--meta` entries in object metadata. me/org require a Phoenix
21
+ // identity (BYO WRITE_TOKEN cannot publish them). org from a public inbox
22
+ // domain is 400. Overwriting an existing slug first copies the current
23
+ // object to <slug>/rev-<ts>-<rand> (revision history) unless
22
24
  // x-share-no-revision is set.
23
- // - GET /<username>/<slug> — public; streams the object from R2, 410s (and lazily
25
+ // - GET /<username>/<slug> — public|unlisted are anonymous; me requires the
26
+ // Phoenix owner, org requires a same-domain Phoenix identity (Bearer, then
27
+ // HMAC cookie, then phoenix_ticket). Unauthenticated me/org 302s to
28
+ // Phoenix login (or 401 JSON if PHOENIX_ID_BASE is unset). 410s (and lazily
24
29
  // deletes) once its expiry has passed. A bucket lifecycle rule is the durable
25
30
  // sweeper; this is the immediate gate.
26
31
  // - GET /<username>/<slug>?revisions=json — machine-readable history of the
@@ -78,6 +83,19 @@ export default {
78
83
  const vis = normalizeVisibility(request.headers.get('x-share-visibility'));
79
84
  if (vis.error) return vis.error;
80
85
  const visibility = vis.value;
86
+ if (visibility === 'me' || visibility === 'org') {
87
+ const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
88
+ if (auth.kind !== 'phoenix' || !phoenixBase) {
89
+ return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
90
+ }
91
+ if (visibility === 'org') {
92
+ const domain = emailDomain(auth.email);
93
+ if (!domain) return json({ error: 'org visibility requires a verified email domain' }, 400);
94
+ if (PUBLIC_INBOX_DOMAINS.indexOf(domain) !== -1) {
95
+ return json({ error: 'org visibility cannot use a public email domain', domain: domain }, 400);
96
+ }
97
+ }
98
+ }
81
99
  const segments = path.split('/').filter(Boolean);
82
100
  if (auth.kind === 'phoenix') {
83
101
  const expected = phoenixHandle(auth);
@@ -90,7 +108,8 @@ export default {
90
108
  const expiresAt = request.headers.get('x-share-expires-at') || '';
91
109
  // 'unlisted' hides the page from the public gallery + JSON listing while
92
110
  // keeping the direct URL world-readable (capability URL, not secret).
93
- // GET also sends X-Robots-Tag: noindex for unlisted objects.
111
+ // me/org are also hidden from gallery/listing; GET is identity-gated
112
+ // and always sends X-Robots-Tag: noindex (same as unlisted).
94
113
  const contentType = request.headers.get('content-type') || 'text/html; charset=utf-8';
95
114
  // Provenance (RUSH-2683): captured client-side from the exec env/git/clock,
96
115
  // never invented here — a header is simply absent when the CLI had nothing
@@ -131,6 +150,7 @@ export default {
131
150
  ? auth.owner
132
151
  : (env.SHARE_NAMESPACE || segments[0] || 'byo');
133
152
  if (owner) customMetadata['owner'] = owner;
153
+ if (visibility === 'org') customMetadata['org_domain'] = emailDomain(auth.email);
134
154
  if (agent) customMetadata['agent'] = agent;
135
155
  if (session) customMetadata['session'] = session;
136
156
  if (host) customMetadata['host'] = host;
@@ -194,9 +214,22 @@ export default {
194
214
 
195
215
  // Revision history for one canonical <user>/<slug> key (RUSH-2683). Checked
196
216
  // before the plain object GET below so the query param routes even though
197
- // the canonical key itself resolves to a real object.
217
+ // the canonical key itself resolves to a real object. me/org use the same
218
+ // identity gate as the page GET — listing session/host/repo on an unauthed
219
+ // ?revisions=json must not leak that the page exists.
198
220
  if (segments.length === 2 && url.searchParams.get('revisions') === 'json') {
199
- return renderRevisions(env.BUCKET, url.origin, path, request.method);
221
+ const canonical = await env.BUCKET.get(path);
222
+ if (canonical) {
223
+ const denied = await gateRestrictedGet(request, env, url, canonical);
224
+ if (denied) return denied;
225
+ }
226
+ return renderRevisions(
227
+ env.BUCKET,
228
+ url.origin,
229
+ path,
230
+ request.method,
231
+ canonical && isIdentityGated((canonical.customMetadata && canonical.customMetadata.visibility) || 'public'),
232
+ );
200
233
  }
201
234
 
202
235
  const obj = await env.BUCKET.get(path);
@@ -206,13 +239,19 @@ export default {
206
239
  await env.BUCKET.delete(path);
207
240
  return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
208
241
  }
242
+ const denied = await gateRestrictedGet(request, env, url, obj);
243
+ if (denied) return denied;
244
+ const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
209
245
  const headers = new Headers();
210
246
  obj.writeHttpMetadata(headers);
211
247
  headers.set('etag', obj.httpEtag);
212
248
  if (!headers.has('content-type')) headers.set('content-type', 'text/html; charset=utf-8');
213
- headers.set('cache-control', 'public, max-age=60');
214
- if (obj.customMetadata && obj.customMetadata.visibility === 'unlisted') {
249
+ if (visibility === 'me' || visibility === 'org') {
250
+ headers.set('cache-control', 'private, no-store');
215
251
  headers.set('X-Robots-Tag', 'noindex');
252
+ } else {
253
+ headers.set('cache-control', 'public, max-age=60');
254
+ if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
216
255
  }
217
256
  return new Response(request.method === 'HEAD' ? null : obj.body, { status: 200, headers });
218
257
  }
@@ -267,8 +306,8 @@ async function renderGallery(bucket, origin, user, method) {
267
306
  const activeObjects = objects.filter(o => {
268
307
  if (isRevisionKey(o.key)) return false;
269
308
  if (o.key.endsWith('.png')) return false;
270
- // Unlisted pages are reachable by direct URL only — never on the gallery.
271
- if (o.customMetadata && o.customMetadata.visibility === 'unlisted') return false;
309
+ // Unlisted / me / org pages are reachable by direct URL only — never on the gallery.
310
+ if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
272
311
  const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
273
312
  return !(expiresAt && Date.now() > Date.parse(expiresAt));
274
313
  });
@@ -330,10 +369,10 @@ async function renderListing(bucket, origin, user, method) {
330
369
  const items = objects
331
370
  .filter(o => {
332
371
  // Mirror the gallery: hide revisions, sibling .png OG covers, expired
333
- // pages, and unlisted pages — the listing is the machine-readable public surface.
372
+ // pages, and unlisted/me/org pages — the listing is the machine-readable public surface.
334
373
  if (isRevisionKey(o.key)) return false;
335
374
  if (o.key.endsWith('.png')) return false;
336
- if (o.customMetadata && o.customMetadata.visibility === 'unlisted') return false;
375
+ if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
337
376
  const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
338
377
  return !(expiresAt && now > Date.parse(expiresAt));
339
378
  })
@@ -364,7 +403,7 @@ async function renderListing(bucket, origin, user, method) {
364
403
  });
365
404
  }
366
405
 
367
- async function renderRevisions(bucket, origin, key, method) {
406
+ async function renderRevisions(bucket, origin, key, method, identityGated) {
368
407
  const objects = [];
369
408
  let cursor;
370
409
  do {
@@ -390,12 +429,15 @@ async function renderRevisions(bucket, origin, key, method) {
390
429
  // Newest first — the most recently replaced version leads.
391
430
  items.sort((a, b) => (a.uploadedAt < b.uploadedAt ? 1 : a.uploadedAt > b.uploadedAt ? -1 : 0));
392
431
 
432
+ const cacheHeaders = identityGated
433
+ ? { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'private, no-store', 'X-Robots-Tag': 'noindex' }
434
+ : { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' };
393
435
  if (method === 'HEAD') {
394
- return new Response(null, { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' } });
436
+ return new Response(null, { status: 200, headers: cacheHeaders });
395
437
  }
396
438
  return new Response(JSON.stringify({ key, count: items.length, revisions: items }), {
397
439
  status: 200,
398
- headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' },
440
+ headers: cacheHeaders,
399
441
  });
400
442
  }
401
443
 
@@ -404,7 +446,10 @@ async function renderRevisions(bucket, origin, key, method) {
404
446
  // before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
405
447
  // One list, reused both to strip a same-named --meta collision on write and
406
448
  // to split arbitrary --meta entries back out on read.
407
- var RESERVED_METADATA_KEYS = ['expires-at', 'visibility', 'owner', 'agent', 'session', 'host', 'repo', 'date', 'label', 'label-source'];
449
+ var RESERVED_METADATA_KEYS = ['expires-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'label', 'label-source'];
450
+ var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
451
+ var SHARE_COOKIE = '__Host-phoenix_share';
452
+ var SHARE_COOKIE_MAX_AGE = 604800;
408
453
 
409
454
  // Everything in customMetadata that ISN'T one of the reserved provenance/label
410
455
  // keys above — i.e. the caller's own \`--meta key=value\` entries. Surfaced on
@@ -495,13 +540,185 @@ async function claimHandle(bucket, handle, userId) {
495
540
  return {};
496
541
  }
497
542
 
498
- // P1 visibility: public | unlisted. 'org' (and anything else) is 400 — org is Phase 2.
499
543
  function normalizeVisibility(raw) {
500
544
  const v = (raw || '').trim().toLowerCase();
501
545
  if (!v || v === 'public') return { value: 'public' };
502
- if (v === 'unlisted') return { value: 'unlisted' };
503
- if (v === 'org') return { error: json({ error: 'visibility org is not supported yet' }, 400) };
504
- return { error: json({ error: 'visibility must be public or unlisted' }, 400) };
546
+ if (v === 'unlisted' || v === 'me' || v === 'org') return { value: v };
547
+ return { error: json({ error: 'visibility must be public, unlisted, me, or org' }, 400) };
548
+ }
549
+
550
+ function emailDomain(email) {
551
+ if (!email) return '';
552
+ const at = String(email).lastIndexOf('@');
553
+ if (at < 0) return '';
554
+ return String(email).slice(at + 1).toLowerCase();
555
+ }
556
+
557
+ function isHiddenFromGallery(visibility) {
558
+ return visibility === 'unlisted' || visibility === 'me' || visibility === 'org';
559
+ }
560
+
561
+ function isIdentityGated(visibility) {
562
+ return visibility === 'me' || visibility === 'org';
563
+ }
564
+
565
+ async function gateRestrictedGet(request, env, url, obj) {
566
+ const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
567
+ if (!isIdentityGated(visibility)) return null;
568
+ const viewer = await resolveViewer(request, env, url);
569
+ if (viewer.redirect) return viewer.redirect;
570
+ if (viewer.error) return viewer.error;
571
+ if (!viewer.identity) return bounceToLogin(url, env);
572
+ if (!viewerMayRead(visibility, obj.customMetadata, viewer.identity)) {
573
+ return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
574
+ }
575
+ return null;
576
+ }
577
+
578
+ function viewerMayRead(visibility, meta, identity) {
579
+ if (visibility === 'me') {
580
+ const owner = meta && meta.owner;
581
+ return !!owner && owner === identity.userId;
582
+ }
583
+ if (visibility === 'org') {
584
+ const stamped = meta && meta.org_domain;
585
+ if (!stamped) return false;
586
+ return emailDomain(identity.email) === String(stamped).toLowerCase();
587
+ }
588
+ return true;
589
+ }
590
+
591
+ function bounceToLogin(url, env) {
592
+ const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
593
+ if (!base) return json({ error: 'phoenix login is not configured' }, 401);
594
+ return new Response(null, {
595
+ status: 302,
596
+ headers: { Location: base + '/login?return=' + encodeURIComponent(url.toString()) },
597
+ });
598
+ }
599
+
600
+ function readCookie(request, name) {
601
+ const header = request.headers.get('Cookie') || '';
602
+ const parts = header.split(';');
603
+ for (let i = 0; i < parts.length; i++) {
604
+ const p = parts[i];
605
+ const eq = p.indexOf('=');
606
+ if (eq < 0) continue;
607
+ if (p.slice(0, eq).trim() === name) return p.slice(eq + 1).trim();
608
+ }
609
+ return '';
610
+ }
611
+
612
+ function toB64Url(s) {
613
+ const bytes = new TextEncoder().encode(s);
614
+ let bin = '';
615
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
616
+ return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
617
+ }
618
+
619
+ function fromB64Url(s) {
620
+ const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
621
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
622
+ const bin = atob(b64);
623
+ const bytes = new Uint8Array(bin.length);
624
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
625
+ return new TextDecoder().decode(bytes);
626
+ }
627
+
628
+ function bufToHex(buf) {
629
+ return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
630
+ }
631
+
632
+ async function hmacHex(secret, data) {
633
+ const key = await crypto.subtle.importKey(
634
+ 'raw',
635
+ new TextEncoder().encode(secret),
636
+ { name: 'HMAC', hash: 'SHA-256' },
637
+ false,
638
+ ['sign'],
639
+ );
640
+ const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
641
+ return bufToHex(sig);
642
+ }
643
+
644
+ async function signShareCookie(identity, env) {
645
+ const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
646
+ if (!secret) return '';
647
+ const exp = Math.floor(Date.now() / 1000) + SHARE_COOKIE_MAX_AGE;
648
+ const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
649
+ const sig = await hmacHex(secret, payload);
650
+ const value = toB64Url(payload) + '.' + sig;
651
+ return SHARE_COOKIE + '=' + value + '; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=' + SHARE_COOKIE_MAX_AGE;
652
+ }
653
+
654
+ async function identityFromCookie(request, env) {
655
+ const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
656
+ if (!secret) return null;
657
+ const raw = readCookie(request, SHARE_COOKIE);
658
+ if (!raw) return null;
659
+ const dot = raw.lastIndexOf('.');
660
+ if (dot < 1) return null;
661
+ let payload;
662
+ try {
663
+ payload = fromB64Url(raw.slice(0, dot));
664
+ } catch {
665
+ return null;
666
+ }
667
+ const sig = raw.slice(dot + 1);
668
+ const expected = await hmacHex(secret, payload);
669
+ if (!safeEqual(sig, expected)) return null;
670
+ const first = payload.indexOf('|');
671
+ const last = payload.lastIndexOf('|');
672
+ if (first < 0 || last <= first) return null;
673
+ const userId = payload.slice(0, first);
674
+ const email = payload.slice(first + 1, last);
675
+ const cookieExp = Number(payload.slice(last + 1));
676
+ if (!userId || !Number.isFinite(cookieExp) || Math.floor(Date.now() / 1000) > cookieExp) return null;
677
+ return { userId: userId, email: email };
678
+ }
679
+
680
+ async function redeemTicket(ticket, env) {
681
+ const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
682
+ if (!base || !ticket) return null;
683
+ let res;
684
+ try {
685
+ res = await fetch(base + '/api/v1/auth/ticket', {
686
+ method: 'POST',
687
+ headers: { 'content-type': 'application/json' },
688
+ body: JSON.stringify({ ticket: ticket }),
689
+ });
690
+ } catch {
691
+ return null;
692
+ }
693
+ if (!res.ok) return null;
694
+ let body;
695
+ try {
696
+ body = await res.json();
697
+ } catch {
698
+ return null;
699
+ }
700
+ if (!body || typeof body.userId !== 'string' || !body.userId) return null;
701
+ return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
702
+ }
703
+
704
+ async function resolveViewer(request, env, url) {
705
+ const claims = await hooks.verifyPhoenixToken(request, env);
706
+ if (claims && typeof claims.userId === 'string' && claims.userId) {
707
+ return { identity: { userId: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' } };
708
+ }
709
+ const cookieId = await identityFromCookie(request, env);
710
+ if (cookieId) return { identity: cookieId };
711
+ const ticket = url.searchParams.get('phoenix_ticket');
712
+ if (!ticket) return {};
713
+ const redeemed = await redeemTicket(ticket, env);
714
+ if (!redeemed) return { error: json({ error: 'invalid ticket' }, 401) };
715
+ const cookie = await signShareCookie(redeemed, env);
716
+ if (!cookie) return { error: json({ error: 'phoenix login is not configured' }, 401) };
717
+ url.searchParams.delete('phoenix_ticket');
718
+ const headers = new Headers();
719
+ headers.set('Location', url.toString());
720
+ headers.append('Set-Cookie', cookie);
721
+ return { redirect: new Response(null, { status: 302, headers: headers }) };
505
722
  }
506
723
 
507
724
  async function defaultVerifyPhoenixToken(request, env) {
@@ -1,6 +1,5 @@
1
1
  import { captureProcessStartTime } from '../platform/index.js';
2
2
  import { AgentType } from './parsers.js';
3
- import { NoViableDeviceError } from './scheduler.js';
4
3
  /**
5
4
  * Compute the Lowest Common Ancestor (LCA) of multiple file paths.
6
5
  * Returns the deepest common directory shared by all paths.
@@ -27,6 +26,16 @@ export declare enum AgentStatus {
27
26
  export declare const TERMINAL_STATUSES: ReadonlySet<AgentStatus>;
28
27
  /** True when a teammate has reached a terminal (completed/failed/stopped) status. */
29
28
  export declare function isTerminalStatus(status: AgentStatus): boolean;
29
+ export type TeammateFailureStage = 'placement' | 'spawn' | 'execution' | 'dependency' | 'cloud';
30
+ /** Durable evidence observed at a concrete teammate lifecycle boundary. */
31
+ export interface TeammateFailure {
32
+ stage: TeammateFailureStage;
33
+ code: string;
34
+ message: string;
35
+ exit_code: number | null;
36
+ retryable: boolean;
37
+ observed_at: string;
38
+ }
30
39
  /**
31
40
  * One remote teammate's liveness, resolved by a single host probe. Three states,
32
41
  * kept distinct on purpose (RUSH-2366):
@@ -201,6 +210,7 @@ export declare class AgentProcess {
201
210
  remotePid: number | null;
202
211
  remoteLog: string | null;
203
212
  remoteExit: string | null;
213
+ failure: TeammateFailure | null;
204
214
  remoteLogOffset: number;
205
215
  remotePollSnapshot: RemoteLivenessSnapshot | null;
206
216
  private eventsCache;
@@ -227,6 +237,7 @@ export declare class AgentProcess {
227
237
  cloud_session_id: string | null;
228
238
  cloud_repo: string | null;
229
239
  cloud_branch: string | null;
240
+ failure: TeammateFailure | null;
230
241
  agent_dir: string;
231
242
  cwd: string | null;
232
243
  }>;
@@ -432,6 +443,8 @@ export declare class AgentManager {
432
443
  */
433
444
  setCloudDispatcher(fn: CloudDispatchFn | null): void;
434
445
  registerAgent(agent: AgentProcess): void;
446
+ private failAgent;
447
+ private deferAgent;
435
448
  /**
436
449
  * Scan the agents dir for meta.json files not already in the in-memory
437
450
  * cache and load them. Needed when another process (e.g. a Planner
@@ -553,16 +566,6 @@ export declare class AgentManager {
553
566
  private maybeSchedulePlacement;
554
567
  /** Human label of a teammate's agent for the placement fail-loud message. */
555
568
  private placementAgentLabel;
556
- /**
557
- * Pre-flight the team pool at `teams start` (RUSH-2002): probe the pool and
558
- * confirm each distinct agent among the pending, unpinned, pooled teammates
559
- * has at least one viable device. Returns the first {@link NoViableDeviceError}
560
- * so the command can fail loud BEFORE launching a wave, rather than silently
561
- * leaving teammates stranded pending. A poolless team (no `--devices`) or a
562
- * probe that could not gather positive evidence returns null — fail-loud fires
563
- * only on proof that no device can run the agent, never on a probe miss.
564
- */
565
- preflightPlacement(taskName: string): Promise<NoViableDeviceError | null>;
566
569
  /**
567
570
  * One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
568
571
  * The supervisor calls this each wave BEFORE listByTask() so the per-teammate
@@ -659,6 +662,5 @@ export declare class AgentManager {
659
662
  alreadyStopped: string[];
660
663
  }>;
661
664
  stop(agentId: string): Promise<boolean>;
662
- private cleanupPartialAgent;
663
665
  private cleanupOldAgents;
664
666
  }