@agent-team-foundation/first-tree-hub 0.11.4 → 0.11.5

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.
@@ -1,12 +1,12 @@
1
1
  import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw8zbkd.mjs";
2
2
  import { A as FIRST_TREE_HUB_ATTR, C as stampOrgScope, D as untrustedAttrs, E as startWsConnectionSpan, M as require_pino, O as withSpan, S as stampChatResource, _ as rootLogger$1, a as buildRateLimitError, c as currentTraceId, f as messageAttrs, g as reportErrorToRoot, i as bodyCaptureOnSendHook, j as redactUrl, k as withWsMessageSpan, l as decodeJwtForTrace, m as observabilityPlugin, n as applyLoggerConfig, o as classifyJoseError, r as attachRequestContext, s as createLogger$1, t as adapterAttrs, u as endWsConnectionSpan, x as stampAgentResource, y as setWsConnectionAttrs } from "./observability-BAScT_5S-gw1ODB_o.mjs";
3
- import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-D-Yf8yOc.mjs";
3
+ import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-C_K2CKXC.mjs";
4
4
  import { a as print, i as blank, n as CLI_USER_AGENT, r as COMMAND_VERSION, s as status, t as cliFetch } from "./cli-fetch--tiwKm5S.mjs";
5
- import { $ as loginSchema, A as createAgentSchema, B as githubCallbackQuerySchema, C as agentTypeSchema$1, Ct as updateMemberSchema, D as contextTreeSnapshotSchema, E as connectTokenExchangeSchema, Et as wsAuthFrameSchema, F as createTaskSchema, G as inboxDeliverFrameSchema$1, H as githubStartQuerySchema, I as defaultRuntimeConfigPayload, J as isRedactedEnvValue, K as inboxPollQuerySchema, L as delegateFeishuUserSchema, M as createMeChatSchema, N as createMemberSchema, O as createAdapterConfigSchema, P as createOrgFromMeSchema, Q as listMeChatsQuerySchema, R as dryRunAgentRuntimeConfigSchema, S as agentRuntimeConfigPayloadSchema$1, St as updateClientCapabilitiesSchema, T as clientRegisterSchema, Tt as updateTaskStatusSchema, U as imageInlineContentSchema, V as githubDevCallbackQuerySchema, W as inboxAckFrameSchema, X as joinByInvitationSchema, Y as isReservedAgentName$1, Z as linkTaskChatSchema, _ as addParticipantSchema, _t as taskListQuerySchema, a as AGENT_STATUSES, at as runtimeStateMessageSchema, b as agentBindRequestSchema, bt as updateAgentSchema, ct as selfServiceFeishuBotSchema, d as TASK_CREATOR_TYPES, dt as sessionCompletionMessageSchema, et as messageSourceSchema$1, f as TASK_HEALTH_SIGNALS, ft as sessionEventMessageSchema, g as addMeChatParticipantsSchema, gt as stripCode, h as WS_AUTH_FRAME_TIMEOUT_MS, ht as sessionStateMessageSchema, i as AGENT_SOURCES, it as refreshTokenSchema, j as createChatSchema, k as createAdapterMappingSchema, l as MENTION_REGEX, lt as sendMessageSchema, m as TASK_TERMINAL_STATUSES, mt as sessionReconcileRequestSchema, n as AGENT_NAME_REGEX$1, nt as paginationQuerySchema, o as AGENT_TYPES, ot as safeRedirectPath, p as TASK_STATUSES, pt as sessionEventSchema$1, q as isOrgSettingNamespace, r as AGENT_SELECTOR_HEADER$1, rt as rebindAgentSchema, s as AGENT_VISIBILITY, st as scanMentionTokens, t as AGENT_BIND_REJECT_REASONS, tt as notificationQuerySchema, u as ORG_SETTINGS_NAMESPACES$1, ut as sendToAgentSchema, v as adminCreateTaskSchema, vt as updateAdapterConfigSchema, wt as updateOrganizationSchema, x as agentPinnedMessageSchema$1, xt as updateChatSchema, y as adminUpdateTaskSchema, yt as updateAgentRuntimeConfigSchema, z as extractMentions } from "./dist-ClFs4WMj.mjs";
6
- import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-BmyRwN0Y-Dad3eV8F.mjs";
7
- import { C as retireClient, D as touchAgent, E as setRuntimeState, O as unbindAgent, S as registerClient, T as setOffline, _ as listClients, a as claimClient, b as markStaleAgents, c as clients, d as getClient, f as getOnlineCount, g as listActiveAgentsPinnedToClient, h as heartbeatInstance, i as bindAgent, k as updateClientCapabilities, l as deriveAuthState, m as heartbeatClient, n as agents, o as cleanupStaleClients, p as getPresence, r as assertClientOwner, s as cleanupStalePresence, t as agentPresence, u as disconnectClient, v as listClientsForOrgAdmin, w as serverInstances, x as members } from "./client-CLdRbuml-svTO0Eat.mjs";
5
+ import { $ as loginSchema, A as createAgentSchema, B as githubCallbackQuerySchema, C as agentTypeSchema$1, Ct as updateChatSchema, D as contextTreeSnapshotSchema, Dt as updateTaskStatusSchema, E as connectTokenExchangeSchema, Et as updateOrganizationSchema, F as createTaskSchema, G as inboxDeliverFrameSchema$1, H as githubStartQuerySchema, I as defaultRuntimeConfigPayload, J as isRedactedEnvValue, K as inboxPollQuerySchema, L as delegateFeishuUserSchema, M as createMeChatSchema, N as createMemberSchema, O as createAdapterConfigSchema, Ot as wsAuthFrameSchema, P as createOrgFromMeSchema, Q as listMeChatsQuerySchema, R as dryRunAgentRuntimeConfigSchema, S as agentRuntimeConfigPayloadSchema$1, St as updateAgentSchema, T as clientRegisterSchema, Tt as updateMemberSchema, U as imageInlineContentSchema, V as githubDevCallbackQuerySchema, W as inboxAckFrameSchema, X as joinByInvitationSchema, Y as isReservedAgentName$1, Z as linkTaskChatSchema, _ as addParticipantSchema, _t as sessionStateMessageSchema, a as AGENT_STATUSES, at as rebindAgentSchema, b as agentBindRequestSchema, bt as updateAdapterConfigSchema, ct as safeRedirectPath, d as TASK_CREATOR_TYPES, dt as sendMessageSchema, et as messageSourceSchema$1, f as TASK_HEALTH_SIGNALS, ft as sendToAgentSchema, g as addMeChatParticipantsSchema, gt as sessionReconcileRequestSchema, h as WS_AUTH_FRAME_TIMEOUT_MS, ht as sessionEventSchema$1, i as AGENT_SOURCES, it as patchOnboardingSchema, j as createChatSchema, k as createAdapterMappingSchema, l as MENTION_REGEX, lt as scanMentionTokens, m as TASK_TERMINAL_STATUSES, mt as sessionEventMessageSchema, n as AGENT_NAME_REGEX$1, nt as onboardingEventSchema, o as AGENT_TYPES, ot as refreshTokenSchema, p as TASK_STATUSES, pt as sessionCompletionMessageSchema, q as isOrgSettingNamespace, r as AGENT_SELECTOR_HEADER$1, rt as paginationQuerySchema, s as AGENT_VISIBILITY, st as runtimeStateMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as notificationQuerySchema, u as ORG_SETTINGS_NAMESPACES$1, ut as selfServiceFeishuBotSchema, v as adminCreateTaskSchema, vt as stripCode, wt as updateClientCapabilitiesSchema, x as agentPinnedMessageSchema$1, xt as updateAgentRuntimeConfigSchema, y as adminUpdateTaskSchema, yt as taskListQuerySchema, z as extractMentions } from "./dist-CfvCT4E0.mjs";
6
+ import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
7
+ import { C as retireClient, D as touchAgent, E as setRuntimeState, O as unbindAgent, S as registerClient, T as setOffline, _ as listClients, a as claimClient, b as markStaleAgents, c as clients, d as getClient, f as getOnlineCount, g as listActiveAgentsPinnedToClient, h as heartbeatInstance, i as bindAgent, k as updateClientCapabilities, l as deriveAuthState, m as heartbeatClient, n as agents, o as cleanupStaleClients, p as getPresence, r as assertClientOwner, s as cleanupStalePresence, t as agentPresence, u as disconnectClient, v as listClientsForOrgAdmin, w as serverInstances, x as members } from "./client-DqdGiggm-NQoGZ2vM.mjs";
8
8
  import { n as init_esm, r as trace, t as esm_exports } from "./esm-Ci8E1Gtj.mjs";
9
- import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-Dnn5gGGX-DXryyvRG.mjs";
9
+ import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-Bg0TRiyx-BsZH4GCS.mjs";
10
10
  import { createRequire } from "node:module";
11
11
  import { ZodError, z } from "zod";
12
12
  import { basename, delimiter, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
@@ -16,7 +16,7 @@ import { EventEmitter } from "node:events";
16
16
  import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
17
17
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
18
18
  import WebSocket from "ws";
19
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
19
+ import { chmod, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
20
20
  import { parse, stringify } from "yaml";
21
21
  import { query } from "@anthropic-ai/claude-agent-sdk";
22
22
  import { execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
@@ -371,160 +371,6 @@ z.object({
371
371
  connected: z.boolean(),
372
372
  lastActiveAt: z.string().nullable()
373
373
  });
374
- const presenceStatusSchema = z.enum(["online", "offline"]);
375
- const runtimeStateSchema = z.enum([
376
- "idle",
377
- "working",
378
- "blocked",
379
- "error"
380
- ]);
381
- z.enum([
382
- "active",
383
- "suspended",
384
- "evicted"
385
- ]);
386
- /** Wire-level states a client may report. `evicted` from a stale client is rejected. */
387
- const clientSessionStateSchema = z.enum(["active", "suspended"]);
388
- z.object({
389
- chatId: z.string().min(1),
390
- state: clientSessionStateSchema
391
- });
392
- z.object({ runtimeState: runtimeStateSchema });
393
- z.object({
394
- agentId: z.string().min(1),
395
- runtimeType: z.string().max(50),
396
- runtimeVersion: z.string().max(50).optional()
397
- });
398
- z.enum([
399
- "wrong_client",
400
- "not_owned",
401
- "agent_suspended",
402
- "wrong_org",
403
- "unknown_agent",
404
- "runtime_provider_mismatch"
405
- ]);
406
- /** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
407
- const AGENT_SELECTOR_HEADER = "x-agent-id";
408
- z.object({
409
- agentId: z.string(),
410
- status: presenceStatusSchema,
411
- connectedAt: z.string().nullable(),
412
- lastSeenAt: z.string(),
413
- clientId: z.string().nullable().optional(),
414
- runtimeType: z.string().nullable().optional(),
415
- runtimeVersion: z.string().nullable().optional(),
416
- runtimeState: runtimeStateSchema.nullable().optional(),
417
- activeSessions: z.number().int().nullable().optional(),
418
- totalSessions: z.number().int().nullable().optional(),
419
- runtimeUpdatedAt: z.string().nullable().optional()
420
- });
421
- z.object({
422
- total: z.number().int(),
423
- running: z.number().int(),
424
- byState: z.object({
425
- idle: z.number().int(),
426
- working: z.number().int(),
427
- blocked: z.number().int(),
428
- error: z.number().int()
429
- }),
430
- clients: z.number().int()
431
- });
432
- const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
433
- const agentTypeSchema = z.enum([
434
- "human",
435
- "personal_assistant",
436
- "autonomous_agent"
437
- ]);
438
- const agentVisibilitySchema = z.enum(["private", "organization"]);
439
- const agentSourceSchema = z.enum(["admin-api", "portal"]);
440
- z.enum(["active", "suspended"]);
441
- /**
442
- * Agent-name rules (see docs/agent-naming-design.md §3.1):
443
- * - Lowercase ASCII slug, hyphens + underscores allowed.
444
- * - Must start with alphanumeric: `-` / `_` as first char collide with
445
- * CLI flag parsing and markdown list syntax.
446
- * - 1–64 chars — aligned with `MENTION_REGEX` so any valid name can be
447
- * @-mentioned in chat. Older rows created under the previous 1–100
448
- * regex are grandfathered; the tight rule only gates new creates.
449
- */
450
- const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
451
- const RESERVED_AGENT_NAMES_SET = new Set([
452
- "admin",
453
- "agent",
454
- "first-tree",
455
- "hub",
456
- "me",
457
- "null",
458
- "system",
459
- "undefined"
460
- ]);
461
- function isReservedAgentName(name) {
462
- return RESERVED_AGENT_NAMES_SET.has(name);
463
- }
464
- z.object({
465
- name: z.string().min(1).max(64).regex(AGENT_NAME_REGEX, "Must start with a letter or digit and contain only lowercase letters, digits, hyphens (-), and underscores (_). Max 64 chars.").refine((n) => !isReservedAgentName(n), { message: "That agent name is reserved — pick a different one." }).optional(),
466
- type: agentTypeSchema,
467
- displayName: z.string().min(1).max(200).optional(),
468
- delegateMention: z.string().max(100).optional(),
469
- organizationId: z.string().min(1).max(100).optional(),
470
- source: agentSourceSchema.optional(),
471
- visibility: agentVisibilitySchema.optional(),
472
- metadata: z.record(z.string(), z.unknown()).optional(),
473
- managerId: z.string().optional(),
474
- clientId: z.string().min(1).max(100).optional(),
475
- runtimeProvider: runtimeProviderSchema.optional()
476
- });
477
- z.object({
478
- type: agentTypeSchema.optional(),
479
- displayName: z.string().min(1).max(200).optional(),
480
- delegateMention: z.string().max(100).nullable().optional(),
481
- visibility: agentVisibilitySchema.optional(),
482
- metadata: z.record(z.string(), z.unknown()).optional(),
483
- managerId: z.string().nullable().optional(),
484
- clientId: z.string().min(1).max(100).nullable().optional()
485
- });
486
- z.object({
487
- clientId: z.string().min(1).max(100),
488
- runtimeProvider: runtimeProviderSchema,
489
- force: z.boolean().optional()
490
- });
491
- z.object({
492
- uuid: z.string(),
493
- name: z.string().nullable(),
494
- organizationId: z.string(),
495
- type: agentTypeSchema,
496
- displayName: z.string(),
497
- delegateMention: z.string().nullable(),
498
- inboxId: z.string(),
499
- status: z.string(),
500
- source: z.string().nullable().optional(),
501
- visibility: agentVisibilitySchema,
502
- metadata: z.record(z.string(), z.unknown()),
503
- managerId: z.string().nullable(),
504
- clientId: z.string().nullable(),
505
- runtimeProvider: runtimeProviderSchema,
506
- presenceStatus: presenceStatusSchema.optional(),
507
- createdAt: z.string(),
508
- updatedAt: z.string()
509
- });
510
- z.object({
511
- repo: z.string().nullable(),
512
- branch: z.string().nullable()
513
- });
514
- /**
515
- * Server → client WebSocket frame announcing that an agent has just been
516
- * pinned to the connected client (either created with `clientId` or bound via
517
- * PATCH NULL → ID). The client can auto-register a local config from this so
518
- * the operator doesn't have to run `first-tree-hub agent add` manually.
519
- */
520
- const agentPinnedMessageSchema = z.object({
521
- type: z.literal("agent:pinned"),
522
- agentId: z.string(),
523
- name: z.string().nullable(),
524
- displayName: z.string(),
525
- agentType: agentTypeSchema,
526
- runtimeProvider: runtimeProviderSchema
527
- });
528
374
  /**
529
375
  * Agent runtime configuration.
530
376
  *
@@ -665,11 +511,31 @@ const agentRuntimeConfigSchema = z.object({
665
511
  updatedAt: z.string(),
666
512
  updatedBy: z.string()
667
513
  });
514
+ /**
515
+ * Write-side shape with no `.default()` per field.
516
+ *
517
+ * `agentRuntimeConfigPayloadShape` carries `.default()` on every field for the
518
+ * read path (so legacy DB rows parse cleanly). On the PATCH side those defaults
519
+ * are actively harmful: Zod 4's `.partial()` makes a field optional but keeps
520
+ * the inner `ZodDefault`, so a body like `{ mcpServers: [...] }` parses to a
521
+ * fully-populated patch where the omitted fields are filled with their
522
+ * defaults — the service layer's `patch.x ?? current.x` then sees a truthy
523
+ * default and *replaces* the user's saved value with empty. Mirroring the 5
524
+ * fields here without defaults keeps "field absent" → `undefined` in the
525
+ * parsed patch, which is what the merge logic expects.
526
+ */
527
+ const agentRuntimeConfigPatchShape = z.object({
528
+ prompt: promptConfigSchema,
529
+ model: z.string(),
530
+ mcpServers: z.array(mcpServerSchema),
531
+ env: z.array(envEntrySchema),
532
+ gitRepos: z.array(gitRepoSchema)
533
+ }).partial();
668
534
  z.object({
669
535
  expectedVersion: z.number().int().positive(),
670
- payload: agentRuntimeConfigPayloadShape.partial()
536
+ payload: agentRuntimeConfigPatchShape
671
537
  });
672
- z.object({ payload: agentRuntimeConfigPayloadShape.partial() });
538
+ z.object({ payload: agentRuntimeConfigPatchShape });
673
539
  z.object({
674
540
  current: agentRuntimeConfigSchema,
675
541
  next: agentRuntimeConfigPayloadSchema,
@@ -694,6 +560,161 @@ function deriveRepoLocalPath(url) {
694
560
  if (!trimmed) return "";
695
561
  return ((trimmed.split(/[?#]/)[0] ?? "").split(/[/:]/).filter(Boolean).pop() ?? "").replace(/\.git$/i, "");
696
562
  }
563
+ const presenceStatusSchema = z.enum(["online", "offline"]);
564
+ const runtimeStateSchema = z.enum([
565
+ "idle",
566
+ "working",
567
+ "blocked",
568
+ "error"
569
+ ]);
570
+ z.enum([
571
+ "active",
572
+ "suspended",
573
+ "evicted"
574
+ ]);
575
+ /** Wire-level states a client may report. `evicted` from a stale client is rejected. */
576
+ const clientSessionStateSchema = z.enum(["active", "suspended"]);
577
+ z.object({
578
+ chatId: z.string().min(1),
579
+ state: clientSessionStateSchema
580
+ });
581
+ z.object({ runtimeState: runtimeStateSchema });
582
+ z.object({
583
+ agentId: z.string().min(1),
584
+ runtimeType: z.string().max(50),
585
+ runtimeVersion: z.string().max(50).optional()
586
+ });
587
+ z.enum([
588
+ "wrong_client",
589
+ "not_owned",
590
+ "agent_suspended",
591
+ "wrong_org",
592
+ "unknown_agent",
593
+ "runtime_provider_mismatch"
594
+ ]);
595
+ /** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
596
+ const AGENT_SELECTOR_HEADER = "x-agent-id";
597
+ z.object({
598
+ agentId: z.string(),
599
+ status: presenceStatusSchema,
600
+ connectedAt: z.string().nullable(),
601
+ lastSeenAt: z.string(),
602
+ clientId: z.string().nullable().optional(),
603
+ runtimeType: z.string().nullable().optional(),
604
+ runtimeVersion: z.string().nullable().optional(),
605
+ runtimeState: runtimeStateSchema.nullable().optional(),
606
+ activeSessions: z.number().int().nullable().optional(),
607
+ totalSessions: z.number().int().nullable().optional(),
608
+ runtimeUpdatedAt: z.string().nullable().optional()
609
+ });
610
+ z.object({
611
+ total: z.number().int(),
612
+ running: z.number().int(),
613
+ byState: z.object({
614
+ idle: z.number().int(),
615
+ working: z.number().int(),
616
+ blocked: z.number().int(),
617
+ error: z.number().int()
618
+ }),
619
+ clients: z.number().int()
620
+ });
621
+ const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
622
+ const agentTypeSchema = z.enum([
623
+ "human",
624
+ "personal_assistant",
625
+ "autonomous_agent"
626
+ ]);
627
+ const agentVisibilitySchema = z.enum(["private", "organization"]);
628
+ const agentSourceSchema = z.enum(["admin-api", "portal"]);
629
+ z.enum(["active", "suspended"]);
630
+ /**
631
+ * Agent-name rules (see docs/agent-naming-design.md §3.1):
632
+ * - Lowercase ASCII slug, hyphens + underscores allowed.
633
+ * - Must start with alphanumeric: `-` / `_` as first char collide with
634
+ * CLI flag parsing and markdown list syntax.
635
+ * - 1–64 chars — aligned with `MENTION_REGEX` so any valid name can be
636
+ * @-mentioned in chat. Older rows created under the previous 1–100
637
+ * regex are grandfathered; the tight rule only gates new creates.
638
+ */
639
+ const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
640
+ const RESERVED_AGENT_NAMES_SET = new Set([
641
+ "admin",
642
+ "agent",
643
+ "first-tree",
644
+ "hub",
645
+ "me",
646
+ "null",
647
+ "system",
648
+ "undefined"
649
+ ]);
650
+ function isReservedAgentName(name) {
651
+ return RESERVED_AGENT_NAMES_SET.has(name);
652
+ }
653
+ z.object({
654
+ name: z.string().min(1).max(64).regex(AGENT_NAME_REGEX, "Must start with a letter or digit and contain only lowercase letters, digits, hyphens (-), and underscores (_). Max 64 chars.").refine((n) => !isReservedAgentName(n), { message: "That agent name is reserved — pick a different one." }).optional(),
655
+ type: agentTypeSchema,
656
+ displayName: z.string().min(1).max(200).optional(),
657
+ delegateMention: z.string().max(100).optional(),
658
+ organizationId: z.string().min(1).max(100).optional(),
659
+ source: agentSourceSchema.optional(),
660
+ visibility: agentVisibilitySchema.optional(),
661
+ metadata: z.record(z.string(), z.unknown()).optional(),
662
+ managerId: z.string().optional(),
663
+ clientId: z.string().min(1).max(100).optional(),
664
+ runtimeProvider: runtimeProviderSchema.optional(),
665
+ gitRepos: z.array(gitRepoSchema).optional()
666
+ });
667
+ z.object({
668
+ type: agentTypeSchema.optional(),
669
+ displayName: z.string().min(1).max(200).optional(),
670
+ delegateMention: z.string().max(100).nullable().optional(),
671
+ visibility: agentVisibilitySchema.optional(),
672
+ metadata: z.record(z.string(), z.unknown()).optional(),
673
+ managerId: z.string().nullable().optional(),
674
+ clientId: z.string().min(1).max(100).nullable().optional()
675
+ });
676
+ z.object({
677
+ clientId: z.string().min(1).max(100),
678
+ runtimeProvider: runtimeProviderSchema,
679
+ force: z.boolean().optional()
680
+ });
681
+ z.object({
682
+ uuid: z.string(),
683
+ name: z.string().nullable(),
684
+ organizationId: z.string(),
685
+ type: agentTypeSchema,
686
+ displayName: z.string(),
687
+ delegateMention: z.string().nullable(),
688
+ inboxId: z.string(),
689
+ status: z.string(),
690
+ source: z.string().nullable().optional(),
691
+ visibility: agentVisibilitySchema,
692
+ metadata: z.record(z.string(), z.unknown()),
693
+ managerId: z.string().nullable(),
694
+ clientId: z.string().nullable(),
695
+ runtimeProvider: runtimeProviderSchema,
696
+ presenceStatus: presenceStatusSchema.optional(),
697
+ createdAt: z.string(),
698
+ updatedAt: z.string()
699
+ });
700
+ z.object({
701
+ repo: z.string().nullable(),
702
+ branch: z.string().nullable()
703
+ });
704
+ /**
705
+ * Server → client WebSocket frame announcing that an agent has just been
706
+ * pinned to the connected client (either created with `clientId` or bound via
707
+ * PATCH NULL → ID). The client can auto-register a local config from this so
708
+ * the operator doesn't have to run `first-tree-hub agent add` manually.
709
+ */
710
+ const agentPinnedMessageSchema = z.object({
711
+ type: z.literal("agent:pinned"),
712
+ agentId: z.string(),
713
+ name: z.string().nullable(),
714
+ displayName: z.string(),
715
+ agentType: agentTypeSchema,
716
+ runtimeProvider: runtimeProviderSchema
717
+ });
697
718
  z.object({
698
719
  username: z.string().min(1),
699
720
  password: z.string().min(1)
@@ -1217,6 +1238,37 @@ z.object({
1217
1238
  name: z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*$/),
1218
1239
  displayName: z.string().min(1).max(200)
1219
1240
  });
1241
+ z.object({ dismissed: z.boolean().optional() });
1242
+ /**
1243
+ * Body for `POST /me/onboarding/events`. The web SPA reports key
1244
+ * milestones so the server can log them as a single funnel-trackable
1245
+ * stream alongside server-emitted events (`team_created`, `dismissed`).
1246
+ *
1247
+ * Server emits:
1248
+ * - `team_created` — at OAuth callback when joinPath === "solo"
1249
+ * - `dismissed` — when PATCH /me/onboarding flips dismissed
1250
+ *
1251
+ * Web reports:
1252
+ * - `team_renamed` — Step 1 user changed the auto-named team
1253
+ * - `agent_created` — Step 2 successfully created the agent
1254
+ * - `tree_chat_started` — Step 3 [Yes, set it up] succeeded
1255
+ * - `tree_intro_dismissed` — Step 3 [I'll do it later] clicked
1256
+ */
1257
+ const onboardingEventNameSchema = z.enum([
1258
+ "team_renamed",
1259
+ "agent_created",
1260
+ "tree_chat_started",
1261
+ "tree_intro_dismissed"
1262
+ ]);
1263
+ z.object({
1264
+ event: onboardingEventNameSchema,
1265
+ attrs: z.record(z.string(), z.union([
1266
+ z.string(),
1267
+ z.number(),
1268
+ z.boolean(),
1269
+ z.null()
1270
+ ])).optional()
1271
+ });
1220
1272
  z.object({
1221
1273
  id: z.string(),
1222
1274
  organizationId: z.string(),
@@ -1311,12 +1363,26 @@ z.object({
1311
1363
  * 2. Add a key to `ORG_SETTINGS_NAMESPACES`.
1312
1364
  * 3. Done. No DB migration, no new API route.
1313
1365
  */
1366
+ const orgContextTreeRepoUrlSchema = z.string().url().refine((value) => {
1367
+ try {
1368
+ return new URL(value).protocol === "https:";
1369
+ } catch {
1370
+ return false;
1371
+ }
1372
+ }, { message: "Context Tree repo URL must use HTTPS." }).refine((value) => {
1373
+ try {
1374
+ const url = new URL(value);
1375
+ return url.username.length === 0 && url.password.length === 0;
1376
+ } catch {
1377
+ return false;
1378
+ }
1379
+ }, { message: "Context Tree repo URL must not include credentials." });
1314
1380
  const orgContextTreeStorageSchema = z.object({
1315
- repo: z.string().url().optional(),
1381
+ repo: orgContextTreeRepoUrlSchema.optional(),
1316
1382
  branch: z.string().default("main")
1317
1383
  });
1318
1384
  const orgContextTreeInputSchema = z.object({
1319
- repo: z.string().url().min(1).nullish(),
1385
+ repo: orgContextTreeRepoUrlSchema.nullish(),
1320
1386
  branch: z.string().min(1).nullish()
1321
1387
  });
1322
1388
  const orgContextTreeOutputSchema = z.object({
@@ -1735,6 +1801,13 @@ defineConfig({
1735
1801
  refreshTokenExpiry: field(z.string().default("30d"), { env: "FIRST_TREE_HUB_AUTH_REFRESH_TOKEN_EXPIRY" }),
1736
1802
  connectTokenExpiry: field(z.string().default("10m"), { env: "FIRST_TREE_HUB_AUTH_CONNECT_TOKEN_EXPIRY" })
1737
1803
  },
1804
+ contextTreeSync: optional({
1805
+ githubToken: field(z.string(), {
1806
+ env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN",
1807
+ secret: true
1808
+ }),
1809
+ githubTokenRepos: field(z.string().optional(), { env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN_REPOS" })
1810
+ }),
1738
1811
  oauth: optional({ github: optional({
1739
1812
  clientId: field(z.string(), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID" }),
1740
1813
  clientSecret: field(z.string(), {
@@ -8303,7 +8376,7 @@ async function onboardCreate(args) {
8303
8376
  }
8304
8377
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
8305
8378
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
8306
- const { bindFeishuBot } = await import("./feishu-AI3pwmqN.mjs").then((n) => n.r);
8379
+ const { bindFeishuBot } = await import("./feishu-DbSvp9UH.mjs").then((n) => n.r);
8307
8380
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
8308
8381
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
8309
8382
  else {
@@ -9516,7 +9589,7 @@ function createFeedbackHandler(config) {
9516
9589
  return { handle };
9517
9590
  }
9518
9591
  //#endregion
9519
- //#region ../server/dist/app-B8Ncyl76.mjs
9592
+ //#region ../server/dist/app--DB1keQE.mjs
9520
9593
  var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
9521
9594
  init_esm();
9522
9595
  var __defProp = Object.defineProperty;
@@ -11026,29 +11099,33 @@ async function createAgent(db, data, options = {}) {
11026
11099
  }
11027
11100
  const resolvedDisplayName = data.displayName?.trim() || name || "Unnamed Agent";
11028
11101
  try {
11029
- const [agent] = await db.insert(agents).values({
11030
- uuid,
11031
- name,
11032
- organizationId: orgId,
11033
- type: data.type,
11034
- displayName: resolvedDisplayName,
11035
- delegateMention: data.delegateMention ?? null,
11036
- inboxId,
11037
- source: data.source ?? null,
11038
- visibility: data.visibility ?? defaultVisibility(data.type),
11039
- metadata: data.metadata ?? {},
11040
- managerId,
11041
- clientId,
11042
- runtimeProvider
11043
- }).returning();
11044
- if (!agent) throw new Error("Unexpected: INSERT RETURNING produced no row");
11045
- await db.insert(agentConfigs).values({
11046
- agentId: agent.uuid,
11047
- version: 1,
11048
- payload: defaultRuntimeConfigPayload(runtimeProvider),
11049
- updatedBy: "system"
11050
- }).onConflictDoNothing();
11051
- return agent;
11102
+ return await db.transaction(async (tx) => {
11103
+ const [row] = await tx.insert(agents).values({
11104
+ uuid,
11105
+ name,
11106
+ organizationId: orgId,
11107
+ type: data.type,
11108
+ displayName: resolvedDisplayName,
11109
+ delegateMention: data.delegateMention ?? null,
11110
+ inboxId,
11111
+ source: data.source ?? null,
11112
+ visibility: data.visibility ?? defaultVisibility(data.type),
11113
+ metadata: data.metadata ?? {},
11114
+ managerId,
11115
+ clientId,
11116
+ runtimeProvider
11117
+ }).returning();
11118
+ if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
11119
+ const initialPayload = defaultRuntimeConfigPayload(runtimeProvider);
11120
+ if (data.gitRepos && data.gitRepos.length > 0) initialPayload.gitRepos = data.gitRepos;
11121
+ await tx.insert(agentConfigs).values({
11122
+ agentId: row.uuid,
11123
+ version: 1,
11124
+ payload: initialPayload,
11125
+ updatedBy: "system"
11126
+ }).onConflictDoNothing();
11127
+ return row;
11128
+ });
11052
11129
  } catch (err) {
11053
11130
  if ((err?.code ?? err?.cause?.code ?? "") === "23505" && name) throw new ConflictError(`Agent name "${name}" already exists in organization "${orgId}"`);
11054
11131
  throw err;
@@ -14704,11 +14781,21 @@ const authIdentities = pgTable("auth_identities", {
14704
14781
  * treats it as a plain string and rejects every password — that's the
14705
14782
  * intended behaviour: SaaS users cannot fall back to password login.
14706
14783
  */
14707
- async function findOrCreateUserFromGithub(db, profile) {
14708
- const [existing] = await db.select({ userId: authIdentities.userId }).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId))).limit(1);
14784
+ async function findOrCreateUserFromGithub(db, profile, opts = {}) {
14785
+ const [existing] = await db.select({
14786
+ userId: authIdentities.userId,
14787
+ metadata: authIdentities.metadata
14788
+ }).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId))).limit(1);
14709
14789
  if (existing) {
14710
- if (profile.email) await db.update(authIdentities).set({
14711
- email: profile.email,
14790
+ const patch = {};
14791
+ if (profile.email) patch.email = profile.email;
14792
+ if (opts.encryptedAccessToken) patch.metadata = {
14793
+ ...existing.metadata ?? {},
14794
+ accessToken: opts.encryptedAccessToken,
14795
+ login: profile.login
14796
+ };
14797
+ if (Object.keys(patch).length > 0) await db.update(authIdentities).set({
14798
+ ...patch,
14712
14799
  updatedAt: /* @__PURE__ */ new Date()
14713
14800
  }).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId)));
14714
14801
  return { userId: existing.userId };
@@ -14724,6 +14811,8 @@ async function findOrCreateUserFromGithub(db, profile) {
14724
14811
  displayName: profile.displayName?.trim() || profile.login,
14725
14812
  avatarUrl: profile.avatarUrl ?? null
14726
14813
  });
14814
+ const metadata = { login: profile.login };
14815
+ if (opts.encryptedAccessToken) metadata.accessToken = opts.encryptedAccessToken;
14727
14816
  await tx.insert(authIdentities).values({
14728
14817
  id: uuidv7(),
14729
14818
  userId,
@@ -14731,7 +14820,7 @@ async function findOrCreateUserFromGithub(db, profile) {
14731
14820
  identifier: profile.githubId,
14732
14821
  email: profile.email,
14733
14822
  verifiedAt: /* @__PURE__ */ new Date(),
14734
- metadata: { login: profile.login }
14823
+ metadata
14735
14824
  });
14736
14825
  });
14737
14826
  return { userId };
@@ -14813,13 +14902,57 @@ async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
14813
14902
  }
14814
14903
  }
14815
14904
  return {
14816
- githubId: String(user.id),
14817
- login: user.login,
14818
- email,
14819
- displayName: user.name ?? null,
14820
- avatarUrl: user.avatar_url ?? null
14905
+ profile: {
14906
+ githubId: String(user.id),
14907
+ login: user.login,
14908
+ email,
14909
+ displayName: user.name ?? null,
14910
+ avatarUrl: user.avatar_url ?? null
14911
+ },
14912
+ accessToken: tokenJson.access_token
14821
14913
  };
14822
14914
  }
14915
+ /**
14916
+ * Thrown when GitHub's API returns a non-2xx for a token-scoped call.
14917
+ * Carries the HTTP status so callers can distinguish auth failures (401 /
14918
+ * 403 — typically a stale token or a missing scope after we expanded to
14919
+ * `repo`) from transient upstream errors.
14920
+ */
14921
+ var GithubApiError = class extends Error {
14922
+ constructor(status, message) {
14923
+ super(message);
14924
+ this.status = status;
14925
+ this.name = "GithubApiError";
14926
+ }
14927
+ };
14928
+ /**
14929
+ * Fetch the authenticated user's accessible repositories. Used by the
14930
+ * Step 2 repo picker. Walks paginated GitHub API responses up to the cap.
14931
+ */
14932
+ async function listUserRepos(accessToken, opts = {}) {
14933
+ const fetcher = opts.fetcher ?? fetch;
14934
+ const perPage = opts.perPage ?? 100;
14935
+ const maxPages = opts.maxPages ?? 3;
14936
+ const out = [];
14937
+ for (let page = 1; page <= maxPages; page++) {
14938
+ const res = await fetcher(`https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=${perPage}&page=${page}`, { headers: {
14939
+ Authorization: `Bearer ${accessToken}`,
14940
+ Accept: "application/vnd.github+json"
14941
+ } });
14942
+ if (!res.ok) throw new GithubApiError(res.status, `GitHub repo list failed (${res.status})`);
14943
+ const rows = await res.json();
14944
+ for (const r of rows) out.push({
14945
+ fullName: r.full_name,
14946
+ cloneUrl: r.clone_url,
14947
+ htmlUrl: r.html_url,
14948
+ private: r.private,
14949
+ defaultBranch: r.default_branch ?? null,
14950
+ pushedAt: r.pushed_at ?? null
14951
+ });
14952
+ if (rows.length < perPage) break;
14953
+ }
14954
+ return out;
14955
+ }
14823
14956
  /** Insert (or reactivate) a `members` row for `userId` in `organizationId`. */
14824
14957
  async function ensureMembership(db, data) {
14825
14958
  const [existing] = await db.select().from(members).where(and(eq(members.userId, data.userId), eq(members.organizationId, data.organizationId))).limit(1);
@@ -14872,14 +15005,15 @@ function sanitizeAgentName(login) {
14872
15005
  * - First try: `${login}` (lowercased, sanitized)
14873
15006
  * - On collision: append a 4-char hex disambiguator
14874
15007
  *
14875
- * Display name is the user's GitHub real name (or login as fallback). No
14876
- * "Personal Team" suffix the user might invite teammates later, and we
14877
- * don't want a label that reads like a private sandbox to be the team name
14878
- * other members see. Users rename freely via Settings.
15008
+ * Default team display name is `${login}'s team` (set by the caller see
15009
+ * docs/new-user-onboarding-design.md §5.5). Reads as "this is a collective
15010
+ * space" from day one so a later teammate-invite doesn't surface a label
15011
+ * that looks like a private sandbox. Users can rename via Step 1 of the
15012
+ * onboarding flow or Settings.
14879
15013
  */
14880
15014
  async function createPersonalTeam(db, input) {
14881
15015
  const baseSlug = sanitizeOrgSlug(input.loginSeed);
14882
- const displayName = input.userDisplayName;
15016
+ const displayName = input.teamDisplayName;
14883
15017
  const orgId = uuidv7();
14884
15018
  return {
14885
15019
  organizationId: orgId,
@@ -15136,7 +15270,7 @@ async function githubOauthRoutes(app) {
15136
15270
  client_id: oauthCfg.clientId,
15137
15271
  redirect_uri: redirectUri,
15138
15272
  state: token,
15139
- scope: "read:user user:email",
15273
+ scope: "read:user user:email repo",
15140
15274
  allow_signup: "true"
15141
15275
  });
15142
15276
  return reply.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
@@ -15160,17 +15294,20 @@ async function githubOauthRoutes(app) {
15160
15294
  }));
15161
15295
  const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
15162
15296
  let profile;
15297
+ let accessToken;
15163
15298
  try {
15164
- profile = await exchangeCodeForProfile({
15299
+ const result = await exchangeCodeForProfile({
15165
15300
  clientId: oauthCfg.clientId,
15166
15301
  clientSecret: oauthCfg.clientSecret
15167
15302
  }, code, redirectUri);
15303
+ profile = result.profile;
15304
+ accessToken = result.accessToken;
15168
15305
  } catch (err) {
15169
15306
  const msg = err instanceof Error ? err.message : "GitHub exchange failed";
15170
15307
  app.log.warn({ err }, "github oauth code exchange failed");
15171
15308
  return reply.status(401).send({ error: msg });
15172
15309
  }
15173
- return completeOauthFlow(app, request, reply, profile, next);
15310
+ return completeOauthFlow(app, request, reply, profile, next, accessToken);
15174
15311
  });
15175
15312
  app.get("/dev-callback", async (request, reply) => {
15176
15313
  if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
@@ -15182,11 +15319,12 @@ async function githubOauthRoutes(app) {
15182
15319
  email: params.email ?? null,
15183
15320
  displayName: params.displayName ?? params.login,
15184
15321
  avatarUrl: null
15185
- }, next);
15322
+ }, next, process.env.DEV_GITHUB_PAT?.trim() || null);
15186
15323
  });
15187
15324
  }
15188
- async function completeOauthFlow(app, request, reply, profile, next) {
15189
- const { userId } = await findOrCreateUserFromGithub(app.db, profile);
15325
+ async function completeOauthFlow(app, request, reply, profile, next, rawAccessToken) {
15326
+ const encryptedAccessToken = rawAccessToken ? encryptValue(rawAccessToken, app.config.secrets.encryptionKey) : void 0;
15327
+ const { userId } = await findOrCreateUserFromGithub(app.db, profile, { encryptedAccessToken });
15190
15328
  let joinPath = "returning";
15191
15329
  const inviteMatch = /^\/invite\/([^/?#]+)/.exec(next);
15192
15330
  let resolved = false;
@@ -15212,14 +15350,21 @@ async function completeOauthFlow(app, request, reply, profile, next) {
15212
15350
  next = "/";
15213
15351
  } else if (await pickPrimaryMembership(app.db, userId)) resolved = true;
15214
15352
  else {
15215
- await createPersonalTeam(app.db, {
15353
+ const personal = await createPersonalTeam(app.db, {
15216
15354
  userId,
15217
15355
  loginSeed: profile.login,
15356
+ teamDisplayName: `${profile.login}'s team`,
15218
15357
  userDisplayName: profile.displayName?.trim() || profile.login
15219
15358
  });
15220
15359
  joinPath = "solo";
15221
15360
  resolved = true;
15222
15361
  next = "/";
15362
+ app.log.info({
15363
+ event: "onboarding.team_created",
15364
+ userId,
15365
+ organizationId: personal.organizationId,
15366
+ source: "oauth-bootstrap"
15367
+ }, "onboarding funnel: team auto-created at OAuth bootstrap");
15223
15368
  }
15224
15369
  if (!resolved) return reply.status(500).send({ error: "Failed to resolve membership" });
15225
15370
  const tokens = await signTokensForUser(app.config.secrets.jwtSecret, userId, app.config.auth);
@@ -16265,8 +16410,11 @@ const MAX_MARKDOWN_FILES = 1e3;
16265
16410
  const MAX_MARKDOWN_FILE_BYTES = 512 * 1024;
16266
16411
  const SNAPSHOT_CACHE_TTL_MS = 3e4;
16267
16412
  const GIT_TIMEOUT_MS = 5e3;
16413
+ const GIT_SYNC_TIMEOUT_MS = 12e4;
16268
16414
  const GIT_MAX_BUFFER = 10 * 1024 * 1024;
16269
16415
  const GIT_LOG_RECORD_SEPARATOR = "";
16416
+ const REMOTE_SYNC_TTL_MS = 6e4;
16417
+ const REMOTE_FAILURE_TTL_MS = 3e4;
16270
16418
  const CONTEXT_TREE_SNAPSHOT_WINDOWS = {
16271
16419
  ONE_DAY: "1d",
16272
16420
  SEVEN_DAYS: "7d",
@@ -16278,10 +16426,14 @@ const WINDOW_DAYS = {
16278
16426
  "30d": 30
16279
16427
  };
16280
16428
  const snapshotCache = /* @__PURE__ */ new Map();
16429
+ const remoteSyncPromises = /* @__PURE__ */ new Map();
16430
+ const remoteLastSyncedAt = /* @__PURE__ */ new Map();
16431
+ const remoteLastSyncWarnings = /* @__PURE__ */ new Map();
16432
+ const remoteLastFailures = /* @__PURE__ */ new Map();
16281
16433
  async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WINDOWS.SEVEN_DAYS) {
16282
16434
  const repo = binding.repo ?? null;
16283
16435
  const branch = binding.branch ?? null;
16284
- const resolved = resolveContextTreeRoot(repo, binding.localPath);
16436
+ const resolved = await resolveContextTreeRoot(repo, binding.localPath, branch, binding.githubToken);
16285
16437
  if (!resolved.root) return unavailableSnapshot(repo, branch, resolved.reason);
16286
16438
  const now = (/* @__PURE__ */ new Date()).toISOString();
16287
16439
  try {
@@ -16291,14 +16443,13 @@ async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WI
16291
16443
  "--abbrev-ref",
16292
16444
  "HEAD"
16293
16445
  ]);
16294
- if (branch && actualBranch && actualBranch !== branch) return unavailableSnapshot(repo, actualBranch, `Context Tree checkout is on branch "${actualBranch}", but server config expects "${branch}".`);
16446
+ if (branch && actualBranch && actualBranch !== branch) return unavailableSnapshot(repo, actualBranch, `Context Tree checkout is on branch "${actualBranch}", but the configured Context Tree branch is "${branch}".`);
16295
16447
  const comparisonBaseCommit = await comparisonBaseForWindow(resolved.root, window);
16296
16448
  const cacheKey = snapshotCacheKey(resolved.root, actualBranch ?? branch, headCommit, comparisonBaseCommit, window);
16297
16449
  const cached = snapshotCache.get(cacheKey);
16298
- if (cached && cached.expiresAt > Date.now()) return {
16299
- ...cached.snapshot,
16300
- syncedAt: now
16301
- };
16450
+ if (cached && cached.expiresAt > Date.now()) {
16451
+ if (!(cached.snapshot.snapshotStatus === "stale" && !resolved.staleReason)) return withSnapshotStatus(cached.snapshot, now, statusWarningFromResolved(resolved.staleReason, null));
16452
+ }
16302
16453
  const tree = buildTree(await readMarkdownFiles(resolved.root));
16303
16454
  const diffResult = comparisonBaseCommit ? await readDiffEntries(resolved.root, comparisonBaseCommit, headCommit) : {
16304
16455
  entries: [],
@@ -16308,18 +16459,14 @@ async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WI
16308
16459
  const nodesWithGhosts = addRemovedGhostNodes(applyChangesToNodes(tree.nodes, changes), changes);
16309
16460
  const summary = summarizeChanges(changes);
16310
16461
  const updates = buildUpdates(changes, nodesWithGhosts);
16311
- const statusWarning = contextStatusWarning(diffResult.truncated);
16462
+ const statusWarning = statusWarningFromResolved(resolved.staleReason, diffResult.truncated);
16312
16463
  const snapshot = {
16313
16464
  repo,
16314
16465
  branch: actualBranch ?? branch,
16315
16466
  headCommit,
16316
16467
  syncedAt: now,
16317
- snapshotStatus: "active",
16318
- contextStatus: {
16319
- label: statusWarning ? "Team context needs attention" : "Team context is current",
16320
- detail: statusWarning ?? "Agents have a synced team context snapshot available.",
16321
- severity: statusWarning ? "warning" : "ok"
16322
- },
16468
+ snapshotStatus: statusWarning?.stale ? "stale" : "active",
16469
+ contextStatus: contextStatus(statusWarning),
16323
16470
  summary,
16324
16471
  updates,
16325
16472
  nodes: nodesWithGhosts,
@@ -16344,31 +16491,249 @@ function snapshotCacheKey(root, branch, headCommit, comparisonBase, window) {
16344
16491
  window
16345
16492
  ].join(":");
16346
16493
  }
16347
- function contextStatusWarning(truncated) {
16348
- if (truncated) return `Showing the first ${MAX_DIFF_ENTRIES} changed files.`;
16494
+ function statusWarningFromResolved(staleReason, truncated) {
16495
+ if (staleReason) return {
16496
+ detail: `${staleReason}${truncated ? ` Showing the first ${MAX_DIFF_ENTRIES} changed files.` : ""}`,
16497
+ stale: true
16498
+ };
16499
+ if (truncated) return {
16500
+ detail: `Showing the first ${MAX_DIFF_ENTRIES} changed files.`,
16501
+ stale: false
16502
+ };
16349
16503
  return null;
16350
16504
  }
16351
- function resolveContextTreeRoot(repo, localPath) {
16352
- const candidate = localPath && localPath.trim().length > 0 ? localPath : repo;
16353
- if (!candidate) return {
16505
+ async function resolveContextTreeRoot(repo, localPath, branch, githubToken) {
16506
+ if (localPath && localPath.trim().length > 0) {
16507
+ const root = resolveLocalPath(localPath);
16508
+ if (existsSync(root)) return {
16509
+ root,
16510
+ reason: "ok",
16511
+ staleReason: null
16512
+ };
16513
+ return {
16514
+ root: null,
16515
+ reason: `Context Tree checkout not found at ${root}.`,
16516
+ staleReason: null
16517
+ };
16518
+ }
16519
+ if (!repo) return {
16354
16520
  root: null,
16355
- reason: "Context Tree is not configured."
16521
+ reason: "Context Tree is not configured.",
16522
+ staleReason: null
16356
16523
  };
16357
- const normalized = candidate.startsWith("file://") ? candidate.slice(7) : candidate;
16358
- const root = isAbsolute(normalized) ? normalize(normalized) : resolve(process.cwd(), normalized);
16524
+ if (isRemoteRepo(repo)) {
16525
+ const resolvedBranch = branch ?? "main";
16526
+ if (!isSafeBranchName(resolvedBranch)) return {
16527
+ root: null,
16528
+ reason: `Configured Context Tree branch "${resolvedBranch}" is invalid.`,
16529
+ staleReason: null
16530
+ };
16531
+ try {
16532
+ const materialized = await materializeRemoteContextTree(repo, resolvedBranch, void 0, githubToken);
16533
+ return {
16534
+ root: materialized.root,
16535
+ reason: "ok",
16536
+ staleReason: materialized.staleReason
16537
+ };
16538
+ } catch (error) {
16539
+ return {
16540
+ root: null,
16541
+ reason: `Hub could not sync the configured Context Tree repo. Check repo access and branch "${resolvedBranch}". ${errorMessage(error)}`,
16542
+ staleReason: null
16543
+ };
16544
+ }
16545
+ }
16546
+ const root = resolveLocalPath(repo);
16359
16547
  if (existsSync(root)) return {
16360
16548
  root,
16361
- reason: "ok"
16549
+ reason: "ok",
16550
+ staleReason: null
16362
16551
  };
16363
- if (/^https?:\/\//.test(normalized) || /^[^/]+\/[^/]+$/.test(normalized)) return {
16552
+ return {
16364
16553
  root: null,
16365
- reason: "Context Tree repo is configured as a remote URL. Set FIRST_TREE_HUB_CONTEXT_TREE_PATH to a readable local checkout for this version."
16554
+ reason: `Context Tree checkout not found at ${root}.`,
16555
+ staleReason: null
16556
+ };
16557
+ }
16558
+ function resolveLocalPath(value) {
16559
+ const normalized = value.startsWith("file://") ? value.slice(7) : value;
16560
+ return isAbsolute(normalized) ? normalize(normalized) : resolve(process.cwd(), normalized);
16561
+ }
16562
+ function isRemoteRepo(value) {
16563
+ return /^https?:\/\//.test(value) || /^file:\/\//.test(value) || /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(value);
16564
+ }
16565
+ function normalizeRemoteRepoUrl(value) {
16566
+ if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(value)) return `https://github.com/${value}`;
16567
+ return value;
16568
+ }
16569
+ function managedContextTreeCacheRoot() {
16570
+ return join(DEFAULT_DATA_DIR$1, "context-tree-repos");
16571
+ }
16572
+ function managedContextTreePath(repoUrl, branch, cacheRoot = managedContextTreeCacheRoot()) {
16573
+ return join(cacheRoot, createHash("sha256").update(`${repoUrl}\0${branch}`).digest("hex"));
16574
+ }
16575
+ async function materializeRemoteContextTree(repo, branch, cacheRoot = managedContextTreeCacheRoot(), githubToken) {
16576
+ const repoUrl = normalizeRemoteRepoUrl(repo);
16577
+ const root = managedContextTreePath(repoUrl, branch, cacheRoot);
16578
+ const lastSyncedAt = remoteLastSyncedAt.get(root);
16579
+ if (lastSyncedAt && Date.now() - lastSyncedAt < REMOTE_SYNC_TTL_MS && existsSync(join(root, ".git"))) return {
16580
+ root,
16581
+ staleReason: remoteLastSyncWarnings.get(root) ?? null
16582
+ };
16583
+ const lastFailure = remoteLastFailures.get(root);
16584
+ if (lastFailure && Date.now() - lastFailure.failedAt < REMOTE_FAILURE_TTL_MS && !existsSync(join(root, ".git"))) throw new Error(lastFailure.reason);
16585
+ const existing = remoteSyncPromises.get(root);
16586
+ if (existing) return {
16587
+ root,
16588
+ staleReason: (await existing).staleReason
16589
+ };
16590
+ const syncPromise = syncRemoteContextTree(repoUrl, branch, root, cacheRoot, githubToken);
16591
+ remoteSyncPromises.set(root, syncPromise);
16592
+ try {
16593
+ const syncResult = await syncPromise;
16594
+ remoteLastSyncedAt.set(root, Date.now());
16595
+ remoteLastFailures.delete(root);
16596
+ if (syncResult.staleReason) remoteLastSyncWarnings.set(root, syncResult.staleReason);
16597
+ else remoteLastSyncWarnings.delete(root);
16598
+ return {
16599
+ root,
16600
+ staleReason: syncResult.staleReason
16601
+ };
16602
+ } catch (error) {
16603
+ if (!existsSync(join(root, ".git"))) remoteLastFailures.set(root, {
16604
+ failedAt: Date.now(),
16605
+ reason: `Previous Context Tree sync failed recently. ${errorMessage(error)}`
16606
+ });
16607
+ throw error;
16608
+ } finally {
16609
+ remoteSyncPromises.delete(root);
16610
+ }
16611
+ }
16612
+ async function syncRemoteContextTree(repoUrl, branch, root, cacheRoot, githubToken) {
16613
+ await mkdir(cacheRoot, { recursive: true });
16614
+ const env = await gitAuthEnv(repoUrl, cacheRoot, githubToken);
16615
+ if (!existsSync(join(root, ".git"))) {
16616
+ await rm(root, {
16617
+ recursive: true,
16618
+ force: true
16619
+ });
16620
+ await gitOutput(cacheRoot, [
16621
+ "clone",
16622
+ "--branch",
16623
+ branch,
16624
+ "--single-branch",
16625
+ repoUrl,
16626
+ root
16627
+ ], {
16628
+ timeout: GIT_SYNC_TIMEOUT_MS,
16629
+ env,
16630
+ disableHooks: true
16631
+ });
16632
+ return { staleReason: null };
16633
+ }
16634
+ try {
16635
+ await gitOutput(root, [
16636
+ "remote",
16637
+ "set-url",
16638
+ "origin",
16639
+ repoUrl
16640
+ ], {
16641
+ timeout: GIT_TIMEOUT_MS,
16642
+ disableHooks: true
16643
+ });
16644
+ await gitOutput(root, [
16645
+ "fetch",
16646
+ "origin",
16647
+ branch,
16648
+ "--prune"
16649
+ ], {
16650
+ timeout: GIT_SYNC_TIMEOUT_MS,
16651
+ env,
16652
+ disableHooks: true
16653
+ });
16654
+ await gitOutput(root, [
16655
+ "checkout",
16656
+ "-B",
16657
+ branch,
16658
+ `origin/${branch}`
16659
+ ], {
16660
+ timeout: GIT_TIMEOUT_MS,
16661
+ disableHooks: true
16662
+ });
16663
+ return { staleReason: null };
16664
+ } catch (error) {
16665
+ if (existsSync(join(root, ".git"))) return { staleReason: `Showing the last synced Context Tree snapshot because Hub could not refresh the configured repo. ${errorMessage(error)}` };
16666
+ throw error;
16667
+ }
16668
+ }
16669
+ async function gitAuthEnv(repoUrl, cacheRoot, githubToken) {
16670
+ if (!githubToken || !isGithubHttpsRepo(repoUrl)) return void 0;
16671
+ const askpassPath = join(cacheRoot, ".tools", "git-askpass.sh");
16672
+ if (!existsSync(askpassPath)) {
16673
+ await mkdir(dirname(askpassPath), { recursive: true });
16674
+ await writeFile(askpassPath, [
16675
+ "#!/bin/sh",
16676
+ "case \"$1\" in",
16677
+ "*Username*) printf \"%s\\n\" \"$GIT_USERNAME\" ;;",
16678
+ "*) printf \"%s\\n\" \"$GIT_PASSWORD\" ;;",
16679
+ "esac",
16680
+ ""
16681
+ ].join("\n"), "utf8");
16682
+ await chmod(askpassPath, 448);
16683
+ }
16684
+ return {
16685
+ ...process.env,
16686
+ GIT_ASKPASS: askpassPath,
16687
+ GIT_TERMINAL_PROMPT: "0",
16688
+ GIT_USERNAME: "x-access-token",
16689
+ GIT_PASSWORD: githubToken
16690
+ };
16691
+ }
16692
+ function isGithubHttpsRepo(repoUrl) {
16693
+ try {
16694
+ const url = new URL(repoUrl);
16695
+ return url.protocol === "https:" && url.hostname.toLowerCase() === "github.com";
16696
+ } catch {
16697
+ return false;
16698
+ }
16699
+ }
16700
+ function contextStatus(warning) {
16701
+ if (warning?.stale) return {
16702
+ label: "Team context is stale",
16703
+ detail: warning.detail,
16704
+ severity: "warning"
16705
+ };
16706
+ if (warning) return {
16707
+ label: "Team context needs attention",
16708
+ detail: warning.detail,
16709
+ severity: "warning"
16366
16710
  };
16367
16711
  return {
16368
- root: null,
16369
- reason: `Context Tree checkout not found at ${root}.`
16712
+ label: "Team context is current",
16713
+ detail: "Agents have a synced team context snapshot available.",
16714
+ severity: "ok"
16370
16715
  };
16371
16716
  }
16717
+ function withSnapshotStatus(snapshot, syncedAt, warning) {
16718
+ return {
16719
+ ...snapshot,
16720
+ syncedAt,
16721
+ snapshotStatus: warning?.stale ? "stale" : snapshot.snapshotStatus,
16722
+ contextStatus: warning ? contextStatus(warning) : snapshot.contextStatus
16723
+ };
16724
+ }
16725
+ function isSafeBranchName(branch) {
16726
+ if (branch.startsWith("-")) return false;
16727
+ if (branch.includes("..") || branch.includes("@{") || branch.includes("\\")) return false;
16728
+ return /^[A-Za-z0-9._/-]+$/.test(branch);
16729
+ }
16730
+ function errorMessage(error) {
16731
+ if (!(error instanceof Error) || error.message.trim().length === 0) return "";
16732
+ return redactSecret(error.message.trim().split("\n")[0] ?? "");
16733
+ }
16734
+ function redactSecret(message) {
16735
+ return message.replace(/(https?:\/\/)[^/@\s]+@/g, "$1[redacted]@").replace(/\bghp_[A-Za-z0-9_]+/g, "[redacted]").replace(/\bgithub_pat_[A-Za-z0-9_]+/g, "[redacted]");
16736
+ }
16372
16737
  function unavailableSnapshot(repo, branch, detail) {
16373
16738
  return {
16374
16739
  repo,
@@ -16393,11 +16758,16 @@ function unavailableSnapshot(repo, branch, detail) {
16393
16758
  changes: []
16394
16759
  };
16395
16760
  }
16396
- async function gitOutput(cwd, args) {
16397
- const { stdout } = await execFileAsync("git", args, {
16761
+ async function gitOutput(cwd, args, options) {
16762
+ const { stdout } = await execFileAsync("git", options?.disableHooks ? [
16763
+ "-c",
16764
+ "core.hooksPath=/dev/null",
16765
+ ...args
16766
+ ] : args, {
16398
16767
  cwd,
16399
- timeout: GIT_TIMEOUT_MS,
16400
- maxBuffer: GIT_MAX_BUFFER
16768
+ timeout: options?.timeout ?? GIT_TIMEOUT_MS,
16769
+ maxBuffer: GIT_MAX_BUFFER,
16770
+ env: options?.env
16401
16771
  });
16402
16772
  return stdout.trim();
16403
16773
  }
@@ -16955,10 +17325,40 @@ async function contextTreeSnapshotRoutes(app) {
16955
17325
  const query = querySchema.parse(request.query);
16956
17326
  const { userId } = requireUser(request);
16957
17327
  const orgId = await resolveUserPrimaryOrgId(app.db, userId);
16958
- const snapshot = await getContextTreeSnapshot(orgId ? await getOrgContextTree(app.db, orgId) : {}, query.window ?? "7d");
17328
+ const binding = orgId ? await getOrgContextTree(app.db, orgId) : {};
17329
+ const githubToken = contextTreeGithubTokenForRepo(binding.repo, app.config.contextTreeSync);
17330
+ const snapshot = await getContextTreeSnapshot({
17331
+ ...binding,
17332
+ githubToken
17333
+ }, query.window ?? "7d");
16959
17334
  return contextTreeSnapshotSchema.parse(snapshot);
16960
17335
  });
16961
17336
  }
17337
+ function contextTreeGithubTokenForRepo(repo, syncConfig) {
17338
+ if (!repo || !syncConfig?.githubToken) return void 0;
17339
+ const repoKey = githubRepoKey(repo);
17340
+ if (!repoKey) return void 0;
17341
+ return new Set((syncConfig.githubTokenRepos ?? "").split(",").map((entry) => normalizeGithubRepoKey(entry)).filter((entry) => entry !== null)).has(repoKey) ? syncConfig.githubToken : void 0;
17342
+ }
17343
+ function githubRepoKey(value) {
17344
+ const shorthand = normalizeGithubRepoKey(value);
17345
+ if (shorthand) return shorthand;
17346
+ let url;
17347
+ try {
17348
+ url = new URL(value);
17349
+ } catch {
17350
+ return null;
17351
+ }
17352
+ if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "github.com") return null;
17353
+ if (url.username || url.password) return null;
17354
+ return normalizeGithubRepoKey(url.pathname.replace(/^\/+/, ""));
17355
+ }
17356
+ function normalizeGithubRepoKey(value) {
17357
+ const trimmed = value.trim().replace(/^\/+/, "").replace(/\.git$/i, "");
17358
+ const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed);
17359
+ if (!match) return null;
17360
+ return `${match[1]?.toLowerCase()}/${match[2]?.toLowerCase()}`;
17361
+ }
16962
17362
  /**
16963
17363
  * Resolve the client IP for rate-limit attribution.
16964
17364
  *
@@ -17060,7 +17460,7 @@ async function healthzRoutes(app) {
17060
17460
  * `api/orgs/invitations.ts` (Class B, admin-gated).
17061
17461
  */
17062
17462
  async function publicInvitationRoutes(app) {
17063
- const { previewInvitation } = await import("./invitation-DWlyNb8x-BvXubk24.mjs");
17463
+ const { previewInvitation } = await import("./invitation-C299fxkP-BR-niZyp.mjs");
17064
17464
  app.get("/:token/preview", async (request, reply) => {
17065
17465
  if (!request.params.token) throw new UnauthorizedError("Token required");
17066
17466
  const preview = await previewInvitation(app.db, request.params.token);
@@ -17083,7 +17483,8 @@ async function meRoutes(app) {
17083
17483
  id: users.id,
17084
17484
  username: users.username,
17085
17485
  displayName: users.displayName,
17086
- avatarUrl: users.avatarUrl
17486
+ avatarUrl: users.avatarUrl,
17487
+ onboardingDismissedAt: users.onboardingDismissedAt
17087
17488
  }).from(users).where(eq(users.id, userId)).limit(1);
17088
17489
  const memberships = await listActiveMemberships(app.db, userId);
17089
17490
  const defaultMembership = pickDefaultMembership(memberships.map((m) => ({
@@ -17098,7 +17499,7 @@ async function meRoutes(app) {
17098
17499
  if (inv) inviteUrl = buildInviteUrl(resolvePublicUrl(app, request), inv.token);
17099
17500
  }
17100
17501
  }
17101
- const wizardStep = await inferWizardStep(app, userId);
17502
+ const onboardingStep = await inferOnboardingStep(app, userId);
17102
17503
  return {
17103
17504
  user: user ?? null,
17104
17505
  defaultOrganizationId: defaultOrgId,
@@ -17109,11 +17510,93 @@ async function meRoutes(app) {
17109
17510
  role: mb.role,
17110
17511
  agentId: mb.agentId
17111
17512
  })),
17112
- wizard: { step: wizardStep },
17513
+ onboarding: {
17514
+ step: onboardingStep,
17515
+ dismissedAt: user?.onboardingDismissedAt ? user.onboardingDismissedAt.toISOString() : null
17516
+ },
17113
17517
  inviteUrl
17114
17518
  };
17115
17519
  });
17116
17520
  /**
17521
+ * PATCH /me/onboarding — currently the only mutable field is
17522
+ * `dismissed`, set when the user clicks `✕` on the onboarding stepper.
17523
+ * Stamping NOW() server-side avoids client-clock skew. Idempotent: a
17524
+ * second PATCH leaves the original timestamp in place.
17525
+ *
17526
+ * See docs/new-user-onboarding-design.md §8.4.
17527
+ */
17528
+ app.patch("/me/onboarding", async (request, reply) => {
17529
+ const { userId } = requireUser(request);
17530
+ const body = patchOnboardingSchema.parse(request.body);
17531
+ if (body.dismissed === true) {
17532
+ if ((await app.db.update(users).set({ onboardingDismissedAt: /* @__PURE__ */ new Date() }).where(and(eq(users.id, userId), isNull(users.onboardingDismissedAt))).returning({ id: users.id })).length > 0) app.log.info({
17533
+ event: "onboarding.dismissed",
17534
+ userId
17535
+ }, "onboarding funnel: stepper dismissed");
17536
+ } else if (body.dismissed === false) await app.db.update(users).set({ onboardingDismissedAt: null }).where(eq(users.id, userId));
17537
+ const [u] = await app.db.select({ onboardingDismissedAt: users.onboardingDismissedAt }).from(users).where(eq(users.id, userId)).limit(1);
17538
+ return reply.status(200).send({ dismissedAt: u?.onboardingDismissedAt ? u.onboardingDismissedAt.toISOString() : null });
17539
+ });
17540
+ /**
17541
+ * POST /me/onboarding/events — web-side onboarding funnel reporter.
17542
+ * Server-side milestones (`team_created` at OAuth, `dismissed` on PATCH)
17543
+ * are emitted directly; this endpoint surfaces the web-driven ones into
17544
+ * the same log stream so a single funnel query covers the full flow.
17545
+ * Body shape is enum-validated so the server won't log arbitrary names.
17546
+ *
17547
+ * Rate-limited to keep a buggy or hostile authenticated tab from
17548
+ * flooding the log stream. The cap is generous relative to legitimate
17549
+ * funnel traffic (≤ 4 events per onboarding pass).
17550
+ */
17551
+ app.post("/me/onboarding/events", { config: { rateLimit: {
17552
+ max: 60,
17553
+ timeWindow: "1 minute"
17554
+ } } }, async (request, reply) => {
17555
+ const { userId } = requireUser(request);
17556
+ const body = onboardingEventSchema.parse(request.body);
17557
+ app.log.info({
17558
+ ...body.attrs ?? {},
17559
+ event: `onboarding.${body.event}`,
17560
+ userId
17561
+ }, `onboarding funnel: ${body.event}`);
17562
+ return reply.status(204).send();
17563
+ });
17564
+ /**
17565
+ * GET /me/github/repos — list the caller's accessible GitHub repos. Used
17566
+ * by the Step 2 onboarding repo picker. The OAuth access token was
17567
+ * captured at sign-in (encrypted at rest in `auth_identities.metadata`)
17568
+ * so this endpoint avoids a second redirect.
17569
+ *
17570
+ * 503 if the user has no GitHub identity bound or the token wasn't
17571
+ * captured (e.g. dev-callback sign-in or pre-redesign user). The web
17572
+ * client falls back to a "Reconnect GitHub" hint in that case.
17573
+ */
17574
+ app.get("/me/github/repos", async (request, reply) => {
17575
+ const { userId } = requireUser(request);
17576
+ const [identity] = await app.db.select({ metadata: authIdentities.metadata }).from(authIdentities).where(and(eq(authIdentities.userId, userId), eq(authIdentities.provider, "github"))).limit(1);
17577
+ const encrypted = identity?.metadata && typeof identity.metadata === "object" && "accessToken" in identity.metadata ? identity.metadata.accessToken : void 0;
17578
+ if (typeof encrypted !== "string" || !encrypted) return reply.status(503).send({ error: "GitHub access token unavailable — please reconnect your account" });
17579
+ let token;
17580
+ try {
17581
+ token = decryptValue(encrypted, app.config.secrets.encryptionKey);
17582
+ } catch {
17583
+ return reply.status(503).send({ error: "GitHub access token could not be decoded — please reconnect" });
17584
+ }
17585
+ try {
17586
+ return { repos: await listUserRepos(token) };
17587
+ } catch (err) {
17588
+ app.log.warn({
17589
+ err,
17590
+ userId
17591
+ }, "list github repos failed");
17592
+ if (err instanceof GithubApiError && (err.status === 401 || err.status === 403)) return reply.status(403).send({
17593
+ error: "GitHub access token is missing the `repo` scope. Please reconnect your GitHub account.",
17594
+ code: "scope_missing"
17595
+ });
17596
+ return reply.status(502).send({ error: "Couldn't reach GitHub. Try again, or reconnect your GitHub account." });
17597
+ }
17598
+ });
17599
+ /**
17117
17600
  * POST /me/connect-tokens — short-lived connect token for the CLI.
17118
17601
  * The token now carries only `sub = userId`; the CLI rejoins via
17119
17602
  * `exchangeConnectToken` which probes `members` realtime.
@@ -17157,7 +17640,7 @@ async function meRoutes(app) {
17157
17640
  */
17158
17641
  app.get("/me/pinned-agents", async (request) => {
17159
17642
  const { userId } = requireUser(request);
17160
- const { listMyPinnedAgents } = await import("./client-By1K4VVT-DuI6EnSh.mjs");
17643
+ const { listMyPinnedAgents } = await import("./client-D_TRJFZY-LbgJF47t.mjs");
17161
17644
  return listMyPinnedAgents(app.db, { userId });
17162
17645
  });
17163
17646
  /**
@@ -17263,24 +17746,24 @@ async function meRoutes(app) {
17263
17746
  return reply.status(204).send();
17264
17747
  });
17265
17748
  /**
17266
- * GET /me/wizard-step — bare endpoint for clients that don't want the
17267
- * full /me payload. Same logic as inferWizardStep below.
17749
+ * GET /me/onboarding-step — bare endpoint for clients that don't want the
17750
+ * full /me payload. Same logic as inferOnboardingStep below.
17268
17751
  */
17269
- app.get("/me/wizard-step", async (request) => {
17752
+ app.get("/me/onboarding-step", async (request) => {
17270
17753
  const { userId } = requireUser(request);
17271
- return { step: await inferWizardStep(app, userId) };
17754
+ return { step: await inferOnboardingStep(app, userId) };
17272
17755
  });
17273
17756
  }
17274
17757
  /**
17275
- * Infer the onboarding wizard step from the *user-level* facts:
17758
+ * Infer the onboarding step from the *user-level* facts:
17276
17759
  * - has at least one client → past "connect"
17277
17760
  * - manages at least one non-human active agent (any org) → past "create_agent"
17278
17761
  *
17279
17762
  * Critically: the join from agents → members → userId means a user with
17280
- * memberships across multiple orgs has the wizard satisfied as soon as ANY
17763
+ * memberships across multiple orgs has onboarding satisfied as soon as ANY
17281
17764
  * org has a non-human agent — matching the user-level mental model.
17282
17765
  */
17283
- async function inferWizardStep(app, userId) {
17766
+ async function inferOnboardingStep(app, userId) {
17284
17767
  const [hasClient] = await app.db.select({ id: clients.id }).from(clients).where(eq(clients.userId, userId)).limit(1);
17285
17768
  if (!hasClient) return "connect";
17286
17769
  const [hasAgent] = await app.db.select({ uuid: agents.uuid }).from(agents).innerJoin(members, eq(members.id, agents.managerId)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.type, "human"), eq(agents.status, "active"))).limit(1);