@oxygen-agent/cli 1.1011.288 → 1.1011.326

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.1011.288
37
+ Version: 1.1011.326
@@ -0,0 +1,11 @@
1
+ import { Command } from "commander";
2
+ import { type ContractFlagOverlay } from "./contract-flags.js";
3
+ type JsonOptions = {
4
+ json?: boolean;
5
+ };
6
+ type Handle = (command: string, options: JsonOptions, action: () => Promise<unknown>) => Promise<void>;
7
+ export declare const CONNECTORS_FLAG_OVERLAY: ContractFlagOverlay;
8
+ /** The PATCH body for the flags given; one of the two workspace flags is required. */
9
+ export declare function buildConnectorUpdateBody(options: Record<string, unknown>): Record<string, unknown>;
10
+ export declare function registerConnectorsCommands(program: Command, handle: Handle): void;
11
+ export {};
@@ -0,0 +1,63 @@
1
+ // `oxygen connectors`: the apps a person connected to OXYGEN with OAuth
2
+ // (ChatGPT, Claude, Codex…) and the workspace each one works in without asking.
3
+ // The set command's flags come from the shared contract that also generates
4
+ // `oxygen_connector_update` and validates the route, so the CLI and MCP cannot
5
+ // disagree about what can be set.
6
+ import { Command } from "commander";
7
+ import { OxygenError } from "@oxygen/shared/cli-result";
8
+ import { CONNECTOR_UPDATE_OPERATION } from "@oxygen/shared/contracts/connectors";
9
+ import { operationPath } from "@oxygen/shared/contracts/operations";
10
+ import { addContractFlags, readContractFlags } from "./contract-flags.js";
11
+ import { requestOxygen } from "./http-client.js";
12
+ const update = CONNECTOR_UPDATE_OPERATION;
13
+ export const CONNECTORS_FLAG_OVERLAY = {
14
+ default_workspace: {
15
+ help: "The workspace this app should work in without asking: its id, slug or exact name from `orgs list`. You must be a member.",
16
+ valueName: "<workspace>",
17
+ },
18
+ };
19
+ /** The PATCH body for the flags given; one of the two workspace flags is required. */
20
+ export function buildConnectorUpdateBody(options) {
21
+ const body = readContractFlags(options, update.contract, CONNECTORS_FLAG_OVERLAY);
22
+ if (!("default_workspace" in body)) {
23
+ throw new OxygenError("invalid_request", "Pass --default-workspace <workspace> to set where the app works, or --clear-default-workspace to have it ask each time.", { exitCode: 1 });
24
+ }
25
+ return body;
26
+ }
27
+ export function registerConnectorsCommands(program, handle) {
28
+ const connectors = program
29
+ .command("connectors")
30
+ .description("Connected apps: ChatGPT, Claude, Codex and any other app you connected to OXYGEN with OAuth, and the default workspace each one works in without asking. "
31
+ + "`connectors list` shows them; `connectors set <app> --default-workspace <workspace>` changes that app only. "
32
+ + "Picking a workspace inside an app changes the same setting, and the latest choice wins. This CLI keeps its own workspace (`orgs use`). "
33
+ + "Web: https://oxygen-agent.com/settings/connected-apps.");
34
+ connectors.addCommand(new Command("list")
35
+ .description("List your connected apps with each one's default workspace, when it was last used, and the workspaces you can choose from. Read-only, 0 credits.")
36
+ .option("--json", "Print a JSON envelope.")
37
+ .action(async (options) => {
38
+ await handle("connectors list", options, () => requestOxygen("/api/cli/connectors"));
39
+ }));
40
+ connectors.addCommand(new Command("get")
41
+ .description("Show one connected app and its default workspace. Read-only, 0 credits.")
42
+ .argument("<connection>", update.cli.argument?.description ?? "The app's id or name.")
43
+ .option("--json", "Print a JSON envelope.")
44
+ .action(async (connection, options) => {
45
+ await handle("connectors get", options, () => requestOxygen(`/api/cli/connectors/${encodeURIComponent(connection)}`));
46
+ }));
47
+ connectors.addCommand(addContractFlags(new Command(update.cli.path.at(-1) ?? "set")
48
+ .description(update.cli.summary)
49
+ .argument(`<${update.cli.argument?.name ?? "connection"}>`, update.cli.argument?.description), update.contract, CONNECTORS_FLAG_OVERLAY)
50
+ .option("--json", "Print a JSON envelope.")
51
+ .addHelpText("after", `
52
+ Examples:
53
+ oxygen connectors list
54
+ oxygen connectors set ChatGPT --default-workspace "Acme GTM"
55
+ oxygen connectors set <id> --clear-default-workspace
56
+ `)
57
+ .action(async (connection, options) => {
58
+ await handle("connectors set", options, () => requestOxygen(operationPath(update, { connection }), {
59
+ method: update.method,
60
+ body: buildConnectorUpdateBody(options),
61
+ }));
62
+ }));
63
+ }
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { stdin as input, stdout as output } from "node:process";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { Command, CommanderError, Option } from "commander";
10
10
  import { registerUgcCommands } from "./ugc-commands.js";
11
+ import { registerConnectorsCommands } from "./connectors-commands.js";
11
12
  import { registerKnowledgeRepositoryCommands } from "./knowledge-repository-commands.js";
12
13
  import { registerVisualCommands } from "./visual-commands.js";
13
14
  import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
@@ -3918,14 +3919,14 @@ export function createProgram() {
3918
3919
  await handleOrgsListAction(options);
3919
3920
  }))
3920
3921
  .addCommand(new Command("use")
3921
- .description("Select the active organization for this CLI profile.")
3922
+ .description("Select the active organization for this CLI profile. Apps connected with OAuth (ChatGPT, Claude…) keep their own default workspace: `oxygen connectors`.")
3922
3923
  .argument("<organization>", "Organization id, Clerk org id, or slug returned by orgs list.")
3923
3924
  .option("--json", "Print a JSON envelope.")
3924
3925
  .action(async (organization, options) => {
3925
3926
  await handleOrgUseAction(organization, options, "orgs use");
3926
3927
  }))
3927
3928
  .addCommand(new Command("select")
3928
- .description("Alias for orgs use.")
3929
+ .description("Alias for orgs use: this CLI profile only. For ChatGPT, Claude or another connected app, use `oxygen connectors set`.")
3929
3930
  .argument("<organization>", "Organization id, Clerk org id, or slug returned by orgs list.")
3930
3931
  .option("--json", "Print a JSON envelope.")
3931
3932
  .action(async (organization, options) => {
@@ -16323,7 +16324,7 @@ Examples:
16323
16324
  });
16324
16325
  }))
16325
16326
  .addCommand(new Command("stats")
16326
- .description("Show one sequence's persisted launch status and current operational state separately, including reasons, open work, lifetime sent/terminal-failed/deferral counts, and the funnel. daily_budget carries today's sends against the caps; operational.reasons carries why work is waiting (outside_send_window, mailbox_spacing, daily_send_cap_reached, ...). For a per-day send series use `sequences analytics --sequence <id> --range <window>`.")
16327
+ .description("Show one sequence's persisted launch status and current operational state separately, including reasons, open work, lifetime sent/terminal-failed/deferral counts, and the funnel. Today's completed sends, reservations and remaining capacity appear in the default output and daily_budget. operational.reasons carries why work is waiting (outside_send_window, mailbox_spacing, daily_send_cap_reached, ...). For a per-day send series use `sequences analytics --sequence <id> --range <window>`.")
16327
16328
  .argument("<sequence>", "Sequence id or slug.")
16328
16329
  .option("--json", "Print a JSON envelope.")
16329
16330
  .action(async (sequence, options) => {
@@ -19739,6 +19740,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
19739
19740
  registerFunctionsCommands(program, handleAsyncAction);
19740
19741
  registerUgcCommands(program, handleAsyncAction);
19741
19742
  registerKnowledgeRepositoryCommands(program, handleAsyncAction);
19743
+ registerConnectorsCommands(program, handleAsyncAction);
19742
19744
  // Last, so every command registered above is covered — including the ones the
19743
19745
  // register* helpers add after applyOxygenHelp.
19744
19746
  enableCommandSuggestions(program);
@@ -26443,6 +26445,7 @@ function formatSequenceReplyRollup(replies) {
26443
26445
  function formatSequenceStatsHealth(data) {
26444
26446
  const stats = recordValue(data.stats);
26445
26447
  const operational = recordValue(stats.operational);
26448
+ const budget = recordValue(data.daily_budget);
26446
26449
  const lines = [
26447
26450
  stringValue(data.name) ?? stringValue(data.sequence_id) ?? "Sequence",
26448
26451
  `Persisted status: ${stringValue(data.status) ?? "unknown"}`,
@@ -26451,6 +26454,14 @@ function formatSequenceStatsHealth(data) {
26451
26454
  `Reasons: ${formatSequenceReasons(operational)}`,
26452
26455
  `${formatFailureWindow(operational.failureAnalytics)} outcomes: ${formatSequenceFailureTotals(operational.failureAnalytics)}`,
26453
26456
  ];
26457
+ const sentToday = numericValue(budget.sends_used);
26458
+ if (sentToday !== null) {
26459
+ const reserved = numericValue(budget.sends_reserved);
26460
+ const remaining = numericValue(budget.sends_remaining);
26461
+ lines.push(`Today${stringValue(budget.day) ? ` (${budget.day} UTC)` : ""}: ${sentToday} sent`
26462
+ + (reserved === null ? "" : ` · ${reserved} reserved`)
26463
+ + ` · ${remaining === null ? "unlimited" : remaining} available`);
26464
+ }
26454
26465
  const failureMix = formatCountRecord(recordValue(recordValue(operational.failureAnalytics).byFailureClass));
26455
26466
  if (failureMix !== "—")
26456
26467
  lines.push(`Failure mix: ${failureMix}`);
@@ -93,6 +93,10 @@ export declare const SEQUENCE_UNSUBSCRIBE_COMMAND = "oxygen sequences update <se
93
93
  * Factories, so a caller can edit the object it gets.
94
94
  */
95
95
  export declare const CAPABILITY_ROUTE_MCP_CALLS: {
96
+ connectedAppsList: () => {
97
+ tool: string;
98
+ arguments: {};
99
+ };
96
100
  sequenceSendingSchedule: () => {
97
101
  tool: string;
98
102
  arguments: {
@@ -22,15 +22,16 @@ export const OXYGEN_CAPABILITY_ROUTES = [
22
22
  id: "workspace-access",
23
23
  layer: "Control",
24
24
  primitive: null,
25
- owns: "Authentication, CLI identities, API keys, sessions, organizations, and active-workspace selection.",
25
+ owns: "Authentication, CLI identities, API keys, sessions, organizations, active-workspace selection, and connected apps (ChatGPT, Claude, Codex…) with the default workspace each one works in.",
26
26
  notFor: "Workspace GTM data, provider credentials, or execution inside a primitive.",
27
- execution: "Resolve identity and select the hosted workspace before operating its primitives.",
27
+ execution: "Resolve identity and select the hosted workspace before operating its primitives. Each app connected with OAuth keeps its own default workspace: `connectors list` shows them and `connectors set` changes one app only; orgs select changes only the app or CLI that calls it.",
28
28
  posture: "mixed",
29
- gatewayTools: ["oxygen_whoami", "oxygen_orgs_list", "oxygen_orgs_select"],
30
- gatewayCommands: ["whoami", "orgs list", "orgs select"],
29
+ gatewayTools: ["oxygen_whoami", "oxygen_orgs_list", "oxygen_orgs_select", "oxygen_connector_list", "oxygen_connector_update"],
30
+ gatewayCommands: ["whoami", "orgs list", "orgs select", "connectors list", "connectors set"],
31
31
  skills: ["oxygen-quickstart"],
32
- endpointSections: ["api-keys", "auth", "login-requests", "orgs", "sessions", "settings", "whoami"],
33
- intentTerms: ["login", "authenticate", "identity", "who am i", "workspace", "organization", "org", "api key", "session", "switch workspace"],
32
+ // `mcp`: the host-driven MCP Events routes an OAuth connection's subscriptions go through (ADR 0030 §2).
33
+ endpointSections: ["api-keys", "auth", "connectors", "login-requests", "mcp", "orgs", "sessions", "settings", "whoami"],
34
+ intentTerms: ["login", "authenticate", "identity", "who am i", "workspace", "organization", "org", "api key", "session", "switch workspace", "connected app", "connected apps", "default workspace"],
34
35
  },
35
36
  {
36
37
  id: "discovery-and-skills",
@@ -827,6 +828,7 @@ export const SEQUENCE_UNSUBSCRIBE_COMMAND = "oxygen sequences update <sequence>
827
828
  * Factories, so a caller can edit the object it gets.
828
829
  */
829
830
  export const CAPABILITY_ROUTE_MCP_CALLS = {
831
+ connectedAppsList: () => ({ tool: "oxygen_connector_list", arguments: {} }),
830
832
  sequenceSendingSchedule: () => ({
831
833
  tool: "oxygen_sequences_update",
832
834
  arguments: { sequence: "<sequence>", sending_schedule: { timezone: "<IANA zone>", days: [1, 2, 3, 4, 5], start: "09:00", end: "17:00" } },
@@ -926,7 +928,30 @@ function isOpenLinkedInRequestsIntent(query) {
926
928
  return /\b(connection|invitation|invite|connect) requests?\b/.test(query)
927
929
  || /\b(pending|open|unanswered|outstanding|received|sent) (linkedin )?(invitations?|invites?)\b/.test(query);
928
930
  }
931
+ /**
932
+ * The apps a person connected with OAuth and where each one works: "make
933
+ * ChatGPT always use workspace X", "default workspace for my Claude
934
+ * connection", "list my connected apps". Both blind baselines (2026-09-30)
935
+ * searched exactly this and got only `orgs select`, which changes the calling
936
+ * app or CLI and never another app; "connection" alone also pulled in the
937
+ * LinkedIn connections card.
938
+ */
939
+ function isConnectedAppsIntent(query) {
940
+ if (/\bconnected apps?\b/.test(query))
941
+ return true;
942
+ const connection = /\b(oauth (?:apps?|clients?|connections?|grants?)|mcp (?:clients?|connections?|connectors?))\b/.test(query);
943
+ const app = connection || /\b(chatgpt|claude|codex|ai (?:apps?|assistants?|clients?))\b/.test(query);
944
+ if (!app)
945
+ return false;
946
+ // An app name alone is not enough ("make Claude always use my brand voice"):
947
+ // the ask must be about which workspace it works in, or list the connections.
948
+ const workspaceChoice = /\b(workspaces?|organi[sz]ations?|orgs?)\b/.test(query)
949
+ && /\b(default|always|without asking|which|per (?:app|connection|client))\b/.test(query);
950
+ return workspaceChoice || (connection && /\b(list|show|see|which|my)\b/.test(query));
951
+ }
929
952
  function explicitCapabilityIntent(query) {
953
+ if (isConnectedAppsIntent(query))
954
+ return ROUTE_BY_ID.get("workspace-access") ?? null;
930
955
  if (/\b(infographic|graphic designer|render html|carousel pages|visual design|gtm flow image)\b/.test(query))
931
956
  return ROUTE_BY_ID.get("visual-rendering") ?? null;
932
957
  if (isUgcIntent(query))
@@ -1236,6 +1261,14 @@ function recommendationsFor(card, query) {
1236
1261
  exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.localBusinessScrape(),
1237
1262
  };
1238
1263
  }
1264
+ if (card.id === "workspace-access" && isConnectedAppsIntent(query)) {
1265
+ return {
1266
+ tools: ["oxygen_connector_list", "oxygen_connector_update"],
1267
+ commands: ["connectors list", "connectors set"],
1268
+ exactCommand: "oxygen connectors list --json",
1269
+ exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.connectedAppsList(),
1270
+ };
1271
+ }
1239
1272
  if (card.id === "connected-linkedin" && isLinkedInAccountConnectIntent(query)) {
1240
1273
  return {
1241
1274
  tools: ["oxygen_senders_connect", "oxygen_senders_list", "oxygen_billing_commitments"],
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `PATCH /api/cli/connectors/{connection}`: the settings one connected app
3
+ * (an MCP OAuth client such as ChatGPT, Claude or Codex) keeps for itself.
4
+ *
5
+ * Today that is its default workspace: the workspace the app works in without
6
+ * asking. It is the same per-connection choice `oxygen_orgs_select` makes from
7
+ * inside the app, so either surface changes one setting and the latest choice
8
+ * wins. The contract is the extension point for later per-connection settings
9
+ * (a per-connection credit ceiling is planned, OXP-11.5.23): add the field here
10
+ * and the route, the CLI flags and the MCP tool follow.
11
+ */
12
+ import type { OperationSpec } from "./operations.js";
13
+ import { type Infer } from "./schema.js";
14
+ export declare const CONNECTOR_UPDATE_CONTRACT: import("./schema.js").ContractObject<{
15
+ readonly default_workspace: {
16
+ oneOf: [import("./schema.js").StringProperty, import("./schema.js").NullProperty];
17
+ description?: string;
18
+ };
19
+ }, never>;
20
+ export type ConnectorUpdate = Infer<typeof CONNECTOR_UPDATE_CONTRACT>;
21
+ /** The route's path value for "the app making this request". */
22
+ export declare const CURRENT_CONNECTOR_REF = "current";
23
+ export declare const CONNECTOR_UPDATE_OPERATION: OperationSpec<typeof CONNECTOR_UPDATE_CONTRACT>;
@@ -0,0 +1,34 @@
1
+ import { nullable, obj, str } from "./schema.js";
2
+ export const CONNECTOR_UPDATE_CONTRACT = obj({
3
+ default_workspace: nullable(str({
4
+ minLength: 1,
5
+ maxLength: 200,
6
+ description: "Workspace id, slug or exact name (you must be a member). null = ask each time.",
7
+ })),
8
+ });
9
+ const inferMatchesRoute = true;
10
+ void inferMatchesRoute;
11
+ /** The route's path value for "the app making this request". */
12
+ export const CURRENT_CONNECTOR_REF = "current";
13
+ export const CONNECTOR_UPDATE_OPERATION = {
14
+ id: "connectors.update",
15
+ route: "/api/cli/connectors/{connection}",
16
+ method: "PATCH",
17
+ contract: CONNECTOR_UPDATE_CONTRACT,
18
+ cli: {
19
+ path: ["connectors", "set"],
20
+ summary: "Set the default workspace of one connected app (ChatGPT, Claude, Codex or another app connected to OXYGEN with OAuth), so that app works there without asking. Only that app changes; your other apps and this CLI keep their own workspace. It is the same setting the app changes when you pick a workspace inside it, so the latest choice wins. --clear-default-workspace makes the app ask again. Free; `connectors list` shows your apps and each one's current default.",
21
+ argument: {
22
+ name: "connection",
23
+ description: "The app's id from `connectors list`, or its name (e.g. ChatGPT) when you have one app of that name.",
24
+ },
25
+ },
26
+ mcp: {
27
+ name: "oxygen_connector_update",
28
+ title: "Oxygen: Set Connected App Workspace",
29
+ description: "Sets the default workspace of one connected app (ChatGPT, Claude, Codex…), leaving your other apps unchanged. Same setting as picking a workspace inside that app; latest wins. connection: id or name from oxygen_connector_list, or \"current\" for this app. Free.",
30
+ pack: "workspace",
31
+ },
32
+ mutates: true,
33
+ spends: false,
34
+ };
@@ -1,9 +1,3 @@
1
- /**
2
- * One entry per customer operation that edits a resource: the route it calls,
3
- * the contract its body satisfies, and the names it carries on the CLI and MCP.
4
- * The CLI command, the MCP tool and the route read the same spec, so the three
5
- * cannot disagree on which fields exist. Leaf and browser-safe.
6
- */
7
1
  import type { ContractObject } from "./schema.js";
8
2
  export type OperationSpec<C extends ContractObject = ContractObject> = {
9
3
  /** Stable id, `<resource>.<verb>`. */
@@ -1,3 +1,10 @@
1
+ /**
2
+ * One entry per customer operation that edits a resource: the route it calls,
3
+ * the contract its body satisfies, and the names it carries on the CLI and MCP.
4
+ * The CLI command, the MCP tool and the route read the same spec, so the three
5
+ * cannot disagree on which fields exist. Leaf and browser-safe.
6
+ */
7
+ import { CONNECTOR_UPDATE_OPERATION } from "./connectors.js";
1
8
  import { SENDER_LIMITS_SET_OPERATION } from "./sender-limits.js";
2
9
  /** The route with its `{param}` segments filled in, each URL-encoded. */
3
10
  export function operationPath(spec, params) {
@@ -8,4 +15,4 @@ export function operationPath(spec, params) {
8
15
  return encodeURIComponent(value);
9
16
  });
10
17
  }
11
- export const OPERATION_SPECS = [SENDER_LIMITS_SET_OPERATION];
18
+ export const OPERATION_SPECS = [SENDER_LIMITS_SET_OPERATION, CONNECTOR_UPDATE_OPERATION];
@@ -80,6 +80,11 @@ export type FeatureGateResolution = {
80
80
  * instead of a sending seat (`packages/shared/src/credit-gate.ts`). A wrong
81
81
  * "on" while commitments do not bill every connectable kind would admit
82
82
  * accounts nothing ever charges.
83
+ * - OXYGEN_MCP_EVENTS_ENABLED — (b). Lets workspace events leave OXYGEN as
84
+ * signed POSTs to third-party callback URLs and lets those events start
85
+ * unattended ChatGPT runs (`packages/shared/src/mcp-events-enabled.ts`, ADR
86
+ * 0030 §11). It stays off in production until the per-connection credit
87
+ * ceiling exists; a remote "on" would bypass that decision.
83
88
  *
84
89
  * The list is a guard, not a to-do: none of these is migrated, and
85
90
  * `findFeatureGateRegistryViolations` fails the build's tests if one ever is.
@@ -71,6 +71,11 @@
71
71
  * instead of a sending seat (`packages/shared/src/credit-gate.ts`). A wrong
72
72
  * "on" while commitments do not bill every connectable kind would admit
73
73
  * accounts nothing ever charges.
74
+ * - OXYGEN_MCP_EVENTS_ENABLED — (b). Lets workspace events leave OXYGEN as
75
+ * signed POSTs to third-party callback URLs and lets those events start
76
+ * unattended ChatGPT runs (`packages/shared/src/mcp-events-enabled.ts`, ADR
77
+ * 0030 §11). It stays off in production until the per-connection credit
78
+ * ceiling exists; a remote "on" would bypass that decision.
74
79
  *
75
80
  * The list is a guard, not a to-do: none of these is migrated, and
76
81
  * `findFeatureGateRegistryViolations` fails the build's tests if one ever is.
@@ -87,6 +92,7 @@ export const NEVER_FLAGGABLE = [
87
92
  "OXYGEN_BYOK_DAILY_CAPS",
88
93
  "OXYGEN_REPRICING_2026_09_EFFECTIVE_AT",
89
94
  "OXYGEN_CREDIT_GATE_ENABLED",
95
+ "OXYGEN_MCP_EVENTS_ENABLED",
90
96
  ];
91
97
  /**
92
98
  * Every server-side gate resolved through PostHog.
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * WHAT IT IS. Exactly once in a workspace's life, OXYGEN researches the customer's
5
5
  * OWN company from the domain the creator typed when the workspace was created
6
- * (control-DB `organizations.iconDomain`), then fills the typed company profile
6
+ * (control-DB `organizations.iconDomain`), or from the company LinkedIn page they
7
+ * typed instead when the company has no website, then fills the typed company profile
7
8
  * (`ox_context.company_profile`) and writes one cited wiki page. A founder who
8
9
  * signs up should not face an empty Knowledge Graph and have to type their own
9
10
  * positioning back at us; every grounded AI action downstream (message drafts, AI
@@ -25,8 +26,9 @@
25
26
  * sub-ceiling on the provider (external-money) half. The cap bounds
26
27
  * OXYGEN's own money now, and it is the same number.
27
28
  * 2. AUTHORIZED — it runs only for a workspace whose creator typed the company
28
- * website into the REQUIRED field at workspace creation; that
29
- * entry is recorded as `knowledge_bootstrap_consent` (source
29
+ * website, or its LinkedIn company page when it has no website
30
+ * (Philipp, 2026-09-30), into the REQUIRED field at workspace
31
+ * creation; that entry is recorded as `knowledge_bootstrap_consent` (source
30
32
  * `workspace_creation`) and read FAIL-CLOSED, so a workspace that
31
33
  * predates the field is never researched and a personal-email
32
34
  * signup with no website never reaches the worker. We never
@@ -86,6 +88,17 @@ export declare const KNOWLEDGE_BOOTSTRAP_MAX_PAGES_READ = 5;
86
88
  * it. This bounds latency only; the credit ceilings above remain the money guard.
87
89
  */
88
90
  export declare const KNOWLEDGE_BOOTSTRAP_PROVIDER_TIMEOUT_MS = 45000;
91
+ /**
92
+ * The company-page lookup's budget when a LinkedIn company page, not a domain, names
93
+ * the company. That lookup is then the pass's only company evidence, and there is no
94
+ * website to fall back on. The managed lane measured successful company lookups at
95
+ * p90 37.1 s, p99 62.2 s and max 68.6 s over 3,138 calls to 2026-09-20
96
+ * (packages/providers/src/scraper/managed.ts), so the 45 s leg budget would abandon
97
+ * real answers. 90 s matches that lane's own abort budget. A pass with a website
98
+ * behind the page can overrun the step deadline in the worst case. The abort check
99
+ * between legs then ends it as `step_aborted`, and the credit ceilings are unchanged.
100
+ */
101
+ export declare const KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_LOOKUP_TIMEOUT_MS = 90000;
89
102
  /** Fleet-wide kill switch, matching OXYGEN_KNOWLEDGE_RETRIEVAL/AUTO_SOURCES_DISABLED. */
90
103
  export declare const KNOWLEDGE_BOOTSTRAP_DISABLED_ENV_VAR = "OXYGEN_KNOWLEDGE_BOOTSTRAP_DISABLED";
91
104
  /**
@@ -140,7 +153,11 @@ export type KnowledgeBootstrapMarker = {
140
153
  run_id: string | null;
141
154
  /** Managed credits actually spent, across every phase. Never exceeds the cap. */
142
155
  credits_used: number;
143
- /** The workspace domain researched (from organizations.iconDomain). */
156
+ /**
157
+ * The workspace domain researched (from organizations.iconDomain). Null (stored
158
+ * as "", the key the execution and onboarding checks compare on) for a company
159
+ * named by its LinkedIn page, which `linkedin_url` then carries.
160
+ */
144
161
  domain: string | null;
145
162
  /** Why a pass was skipped/partial/failed — a short machine-ish token, not prose. */
146
163
  reason: string | null;
@@ -163,7 +180,8 @@ export type KnowledgeBootstrapMarker = {
163
180
  * Canonical company LinkedIn URL the requester supplied, when they supplied one.
164
181
  * The same hint `/setup` stores at KNOWLEDGE_BOOTSTRAP_LINKEDIN_METADATA_KEY, but
165
182
  * carried per-request so a one-off `--linkedin` never mutates the workspace's
166
- * recorded answer.
183
+ * recorded answer. For a company named by its LinkedIn page it is the company
184
+ * itself: the page the automatic pass was claimed for.
167
185
  */
168
186
  linkedin_url?: string | null;
169
187
  };
@@ -180,11 +198,32 @@ export type KnowledgeBootstrapMarker = {
180
198
  * nobody recorded.
181
199
  */
182
200
  export declare const KNOWLEDGE_BOOTSTRAP_CONSENT_METADATA_KEY = "knowledge_bootstrap_consent";
201
+ /**
202
+ * Control-DB `organizations.metadata` key for the consent of a workspace named by its
203
+ * LinkedIn company page because the company has no website (Philipp, 2026-09-30).
204
+ *
205
+ * A key of its own, not a field on the consent above, on purpose. A worker built before
206
+ * it existed reads only that key, and a consent there with a page and no domain would
207
+ * send it to its icon fallback (a domain derived from the creator's email address, so
208
+ * possibly another company) or to a durable `no_company_domain` skip. Under this key the
209
+ * same worker finds no consent at all: a transient `not_consented` that writes nothing,
210
+ * until a worker that reads pages takes the pass. A rolling deploy serves new web code
211
+ * well before the worker roles move, so this is a real window, not a hypothetical one.
212
+ * Writers set exactly one of the two keys.
213
+ */
214
+ export declare const KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_CONSENT_METADATA_KEY = "knowledge_bootstrap_company_page_consent";
183
215
  /** The recorded consent, exactly as stored under the key above. */
184
216
  export type KnowledgeBootstrapConsent = {
185
217
  /** New workspace creation may authorize bounded creator-profile research. */
186
218
  scope?: "company_and_creator";
187
219
  domain?: string;
220
+ /**
221
+ * The company LinkedIn page the creator named INSTEAD of a website, canonical
222
+ * `/company/` form. Present only on a company-page consent
223
+ * (`KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_CONSENT_METADATA_KEY`), which never carries a
224
+ * domain; it is then the company the research pass starts from.
225
+ */
226
+ company_linkedin_url?: string;
188
227
  /** Canonical `/in/` profile supplied by this exact creator; never company evidence. */
189
228
  creator_linkedin_url?: string;
190
229
  /** ISO timestamp consent was recorded. Must parse; a marker that does not is not consent. */
@@ -222,11 +261,21 @@ export declare function readKnowledgeBootstrapConsent(metadata: unknown): Knowle
222
261
  * personal profile as if it were the company is exactly the confidently-wrong write
223
262
  * the bootstrap's domain gate exists to prevent.
224
263
  *
225
- * Optional by design. Its absence is normal and costs nothing: the research pass
226
- * resolves the company from the domain and pays for a LinkedIn lookup only when it
227
- * has a page worth looking up.
264
+ * Optional when the workspace named a domain: the research pass then resolves the
265
+ * company from the domain. For a company with no website it is the only company
266
+ * identity the workspace has, and the consent carries the same page as the anchor
267
+ * the research starts from.
228
268
  */
229
269
  export declare const KNOWLEDGE_BOOTSTRAP_LINKEDIN_METADATA_KEY = "company_linkedin_url";
270
+ /**
271
+ * The company LinkedIn page a workspace names instead of a website, as canonical
272
+ * `https://www.linkedin.com/company/<slug>`, or null. A pasted sub-page
273
+ * (`/company/acme/about/`) resolves to its company; `/school/` pages and personal
274
+ * `/in/` profiles do not, because the research provider looks a company up by
275
+ * exactly this form, and researching a person as if they were the company is the
276
+ * confidently-wrong write the authorization exists to prevent.
277
+ */
278
+ export declare function normalizeBootstrapCompanyLinkedinUrl(value: unknown): string | null;
230
279
  /**
231
280
  * How long a `running` marker may sit before the workspace is reported as STUCK.
232
281
  *
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * WHAT IT IS. Exactly once in a workspace's life, OXYGEN researches the customer's
5
5
  * OWN company from the domain the creator typed when the workspace was created
6
- * (control-DB `organizations.iconDomain`), then fills the typed company profile
6
+ * (control-DB `organizations.iconDomain`), or from the company LinkedIn page they
7
+ * typed instead when the company has no website, then fills the typed company profile
7
8
  * (`ox_context.company_profile`) and writes one cited wiki page. A founder who
8
9
  * signs up should not face an empty Knowledge Graph and have to type their own
9
10
  * positioning back at us; every grounded AI action downstream (message drafts, AI
@@ -25,8 +26,9 @@
25
26
  * sub-ceiling on the provider (external-money) half. The cap bounds
26
27
  * OXYGEN's own money now, and it is the same number.
27
28
  * 2. AUTHORIZED — it runs only for a workspace whose creator typed the company
28
- * website into the REQUIRED field at workspace creation; that
29
- * entry is recorded as `knowledge_bootstrap_consent` (source
29
+ * website, or its LinkedIn company page when it has no website
30
+ * (Philipp, 2026-09-30), into the REQUIRED field at workspace
31
+ * creation; that entry is recorded as `knowledge_bootstrap_consent` (source
30
32
  * `workspace_creation`) and read FAIL-CLOSED, so a workspace that
31
33
  * predates the field is never researched and a personal-email
32
34
  * signup with no website never reaches the worker. We never
@@ -52,6 +54,7 @@
52
54
  * Professional person evidence remains actor-scoped; ICP/offers are hypotheses.
53
55
  * The provider sub-cap covers every external research leg, including fallbacks.
54
56
  */
57
+ import { normalizeLinkedinCompanyUrl } from "./dnc-identities.js";
55
58
  import { normalizeLinkedinProfileUrl } from "./linkedin-url.js";
56
59
  /** Hard ceiling on managed credits ONE workspace bootstrap may spend, all phases. */
57
60
  export const KNOWLEDGE_BOOTSTRAP_MAX_CREDITS = 30;
@@ -86,6 +89,17 @@ export const KNOWLEDGE_BOOTSTRAP_MAX_PAGES_READ = 5;
86
89
  * it. This bounds latency only; the credit ceilings above remain the money guard.
87
90
  */
88
91
  export const KNOWLEDGE_BOOTSTRAP_PROVIDER_TIMEOUT_MS = 45_000;
92
+ /**
93
+ * The company-page lookup's budget when a LinkedIn company page, not a domain, names
94
+ * the company. That lookup is then the pass's only company evidence, and there is no
95
+ * website to fall back on. The managed lane measured successful company lookups at
96
+ * p90 37.1 s, p99 62.2 s and max 68.6 s over 3,138 calls to 2026-09-20
97
+ * (packages/providers/src/scraper/managed.ts), so the 45 s leg budget would abandon
98
+ * real answers. 90 s matches that lane's own abort budget. A pass with a website
99
+ * behind the page can overrun the step deadline in the worst case. The abort check
100
+ * between legs then ends it as `step_aborted`, and the credit ceilings are unchanged.
101
+ */
102
+ export const KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_LOOKUP_TIMEOUT_MS = 90_000;
89
103
  /** Fleet-wide kill switch, matching OXYGEN_KNOWLEDGE_RETRIEVAL/AUTO_SOURCES_DISABLED. */
90
104
  export const KNOWLEDGE_BOOTSTRAP_DISABLED_ENV_VAR = "OXYGEN_KNOWLEDGE_BOOTSTRAP_DISABLED";
91
105
  /**
@@ -149,6 +163,20 @@ export function isKnowledgeBootstrapStatus(value) {
149
163
  * nobody recorded.
150
164
  */
151
165
  export const KNOWLEDGE_BOOTSTRAP_CONSENT_METADATA_KEY = "knowledge_bootstrap_consent";
166
+ /**
167
+ * Control-DB `organizations.metadata` key for the consent of a workspace named by its
168
+ * LinkedIn company page because the company has no website (Philipp, 2026-09-30).
169
+ *
170
+ * A key of its own, not a field on the consent above, on purpose. A worker built before
171
+ * it existed reads only that key, and a consent there with a page and no domain would
172
+ * send it to its icon fallback (a domain derived from the creator's email address, so
173
+ * possibly another company) or to a durable `no_company_domain` skip. Under this key the
174
+ * same worker finds no consent at all: a transient `not_consented` that writes nothing,
175
+ * until a worker that reads pages takes the pass. A rolling deploy serves new web code
176
+ * well before the worker roles move, so this is a real window, not a hypothetical one.
177
+ * Writers set exactly one of the two keys.
178
+ */
179
+ export const KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_CONSENT_METADATA_KEY = "knowledge_bootstrap_company_page_consent";
152
180
  /**
153
181
  * Read consent out of an organization's control-DB metadata blob, FAIL-CLOSED.
154
182
  *
@@ -163,7 +191,22 @@ export const KNOWLEDGE_BOOTSTRAP_CONSENT_METADATA_KEY = "knowledge_bootstrap_con
163
191
  export function readKnowledgeBootstrapConsent(metadata) {
164
192
  if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
165
193
  return null;
166
- const raw = metadata[KNOWLEDGE_BOOTSTRAP_CONSENT_METADATA_KEY];
194
+ const record = metadata;
195
+ const domainConsent = readGrant(record[KNOWLEDGE_BOOTSTRAP_CONSENT_METADATA_KEY]);
196
+ if (domainConsent) {
197
+ const domain = domainConsent.record.domain;
198
+ return { ...domainConsent.grant, ...(typeof domain === "string" ? { domain } : {}) };
199
+ }
200
+ // Fail closed here too: a page consent counts only as the creator-scoped grant it is
201
+ // written as, naming a page that still resolves to a company. Anything less is no
202
+ // consent, never a reason to fall back to a domain nobody named.
203
+ const pageConsent = readGrant(record[KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_CONSENT_METADATA_KEY]);
204
+ const page = normalizeBootstrapCompanyLinkedinUrl(pageConsent?.record.company_linkedin_url);
205
+ if (!pageConsent || pageConsent.grant.scope !== "company_and_creator" || !page)
206
+ return null;
207
+ return { ...pageConsent.grant, company_linkedin_url: page };
208
+ }
209
+ function readGrant(raw) {
167
210
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
168
211
  return null;
169
212
  const record = raw;
@@ -182,12 +225,14 @@ export function readKnowledgeBootstrapConsent(metadata) {
182
225
  ? normalizeLinkedinProfileUrl(record.creator_linkedin_url)?.dispatchUrl ?? null
183
226
  : null;
184
227
  return {
185
- granted_at: grantedAt,
186
- ...(scope ? { scope } : {}),
187
- ...(typeof record.domain === "string" ? { domain: record.domain } : {}),
188
- ...(creatorLinkedinUrl ? { creator_linkedin_url: creatorLinkedinUrl } : {}),
189
- source: typeof record.source === "string" && record.source.length > 0 ? record.source : null,
190
- granted_by_clerk_user_id: grantedBy,
228
+ record,
229
+ grant: {
230
+ granted_at: grantedAt,
231
+ ...(scope ? { scope } : {}),
232
+ ...(creatorLinkedinUrl ? { creator_linkedin_url: creatorLinkedinUrl } : {}),
233
+ source: typeof record.source === "string" && record.source.length > 0 ? record.source : null,
234
+ granted_by_clerk_user_id: grantedBy,
235
+ },
191
236
  };
192
237
  }
193
238
  /**
@@ -203,11 +248,27 @@ export function readKnowledgeBootstrapConsent(metadata) {
203
248
  * personal profile as if it were the company is exactly the confidently-wrong write
204
249
  * the bootstrap's domain gate exists to prevent.
205
250
  *
206
- * Optional by design. Its absence is normal and costs nothing: the research pass
207
- * resolves the company from the domain and pays for a LinkedIn lookup only when it
208
- * has a page worth looking up.
251
+ * Optional when the workspace named a domain: the research pass then resolves the
252
+ * company from the domain. For a company with no website it is the only company
253
+ * identity the workspace has, and the consent carries the same page as the anchor
254
+ * the research starts from.
209
255
  */
210
256
  export const KNOWLEDGE_BOOTSTRAP_LINKEDIN_METADATA_KEY = "company_linkedin_url";
257
+ /**
258
+ * The company LinkedIn page a workspace names instead of a website, as canonical
259
+ * `https://www.linkedin.com/company/<slug>`, or null. A pasted sub-page
260
+ * (`/company/acme/about/`) resolves to its company; `/school/` pages and personal
261
+ * `/in/` profiles do not, because the research provider looks a company up by
262
+ * exactly this form, and researching a person as if they were the company is the
263
+ * confidently-wrong write the authorization exists to prevent.
264
+ */
265
+ export function normalizeBootstrapCompanyLinkedinUrl(value) {
266
+ if (typeof value !== "string")
267
+ return null;
268
+ const companyRoot = value.trim().replace(/(\/company\/[^/?#]+)\/[^?#]*/i, "$1");
269
+ const canonical = normalizeLinkedinCompanyUrl(companyRoot);
270
+ return canonical?.startsWith("https://www.linkedin.com/company/") ? canonical : null;
271
+ }
211
272
  /**
212
273
  * How long a `running` marker may sit before the workspace is reported as STUCK.
213
274
  *