@ccpocket/bridge 1.66.0 → 1.66.2

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.
@@ -253,6 +253,12 @@ export declare class CodexProcess extends EventEmitter<CodexProcessEvents> {
253
253
  approveAlways(toolUseId?: string): void;
254
254
  reject(toolUseId?: string, _message?: string): void;
255
255
  answer(toolUseId: string, result: string): void;
256
+ /**
257
+ * Install a plugin or begin connector authentication proposed by Codex.
258
+ * The elicitation remains pending while external app authentication is
259
+ * required, and is accepted only after installation is complete.
260
+ */
261
+ installToolSuggestion(toolUseId: string): Promise<void>;
256
262
  getPendingPermission(toolUseId?: string): {
257
263
  toolUseId: string;
258
264
  toolName: string;
@@ -269,6 +275,8 @@ export declare class CodexProcess extends EventEmitter<CodexProcessEvents> {
269
275
  * the permission (approve/reject) path.
270
276
  */
271
277
  private approveUserInput;
278
+ private updateToolSuggestion;
279
+ private resolveToolSuggestion;
272
280
  /**
273
281
  * Reject a pending user-input request (McpElicitation fallback).
274
282
  */
@@ -303,6 +311,7 @@ export declare class CodexProcess extends EventEmitter<CodexProcessEvents> {
303
311
  private request;
304
312
  private notify;
305
313
  private respondToServerRequest;
314
+ private respondToServerRequestError;
306
315
  private writeEnvelope;
307
316
  private rejectAllPending;
308
317
  private setStatus;
@@ -611,6 +611,94 @@ export class CodexProcess extends EventEmitter {
611
611
  this.setStatus("running");
612
612
  }
613
613
  }
614
+ /**
615
+ * Install a plugin or begin connector authentication proposed by Codex.
616
+ * The elicitation remains pending while external app authentication is
617
+ * required, and is accepted only after installation is complete.
618
+ */
619
+ async installToolSuggestion(toolUseId) {
620
+ const pending = this.resolvePendingUserInput(toolUseId);
621
+ if (!pending || pending.kind !== "tool_suggestion") {
622
+ throw new Error("No pending tool suggestion found");
623
+ }
624
+ const currentState = pending.input.installState;
625
+ if (currentState === "installing")
626
+ return;
627
+ if (currentState === "needs_auth")
628
+ return;
629
+ const meta = asRecord(pending.input._meta) ?? {};
630
+ const toolType = stringValue(meta.tool_type) ?? "";
631
+ const suggestType = stringValue(meta.suggest_type) ?? "";
632
+ if (suggestType !== "install") {
633
+ this.updateToolSuggestion(pending, {
634
+ installState: "failed",
635
+ installError: `Unsupported suggestion action: ${suggestType || "unknown"}`,
636
+ });
637
+ return;
638
+ }
639
+ if (toolType === "connector") {
640
+ const installUrl = stringValue(meta.install_url);
641
+ if (!installUrl) {
642
+ this.updateToolSuggestion(pending, {
643
+ installState: "failed",
644
+ installError: "This connector did not provide an installation URL.",
645
+ });
646
+ return;
647
+ }
648
+ this.updateToolSuggestion(pending, { installState: "needs_auth" });
649
+ return;
650
+ }
651
+ if (toolType !== "plugin") {
652
+ this.updateToolSuggestion(pending, {
653
+ installState: "failed",
654
+ installError: `Unsupported tool type: ${toolType || "unknown"}`,
655
+ });
656
+ return;
657
+ }
658
+ const toolId = stringValue(meta.tool_id) ?? "";
659
+ const remotePluginId = stringValue(meta.remote_plugin_id);
660
+ const separator = toolId.lastIndexOf("@");
661
+ const fallbackPluginName = separator > 0 ? toolId.slice(0, separator) : toolId;
662
+ const remoteMarketplaceName = separator > 0 ? toolId.slice(separator + 1) : "openai-curated-remote";
663
+ const pluginName = remotePluginId ?? fallbackPluginName;
664
+ if (!pluginName) {
665
+ this.updateToolSuggestion(pending, {
666
+ installState: "failed",
667
+ installError: "This plugin did not provide an installation identifier.",
668
+ });
669
+ return;
670
+ }
671
+ this.updateToolSuggestion(pending, {
672
+ installState: "installing",
673
+ installError: null,
674
+ });
675
+ try {
676
+ const result = (await this.request("plugin/install", {
677
+ remoteMarketplaceName,
678
+ pluginName,
679
+ }));
680
+ // The user may reject the suggestion while installation is in flight.
681
+ if (this.pendingUserInputs.get(toolUseId) !== pending)
682
+ return;
683
+ const appsNeedingAuth = normalizeToolSuggestionApps(result.appsNeedingAuth);
684
+ if (appsNeedingAuth.length > 0) {
685
+ this.updateToolSuggestion(pending, {
686
+ installState: "needs_auth",
687
+ appsNeedingAuth,
688
+ });
689
+ return;
690
+ }
691
+ this.resolveToolSuggestion(pending, "Installed");
692
+ }
693
+ catch (err) {
694
+ if (this.pendingUserInputs.get(toolUseId) !== pending)
695
+ return;
696
+ this.updateToolSuggestion(pending, {
697
+ installState: "failed",
698
+ installError: err instanceof Error ? err.message : String(err),
699
+ });
700
+ }
701
+ }
614
702
  getPendingPermission(toolUseId) {
615
703
  // Check plan completion first
616
704
  if (this.pendingPlanCompletion) {
@@ -669,6 +757,15 @@ export class CodexProcess extends EventEmitter {
669
757
  const pending = this.resolvePendingUserInput(toolUseId);
670
758
  if (!pending)
671
759
  return false;
760
+ if (pending.kind === "tool_suggestion") {
761
+ if (pending.input.installState === "needs_auth") {
762
+ this.resolveToolSuggestion(pending, "Installed");
763
+ }
764
+ else {
765
+ void this.installToolSuggestion(pending.toolUseId);
766
+ }
767
+ return true;
768
+ }
672
769
  this.pendingUserInputs.delete(pending.toolUseId);
673
770
  this.respondToServerRequest(pending.requestId, buildUserInputResponse(pending, result));
674
771
  this.emitToolResult(pending.toolUseId, "Approved");
@@ -677,6 +774,31 @@ export class CodexProcess extends EventEmitter {
677
774
  }
678
775
  return true;
679
776
  }
777
+ updateToolSuggestion(pending, changes) {
778
+ pending.input = { ...pending.input, ...changes };
779
+ this.emitMessage({
780
+ type: "permission_request",
781
+ toolUseId: pending.toolUseId,
782
+ toolName: "ToolSuggestion",
783
+ input: { ...pending.input },
784
+ });
785
+ }
786
+ resolveToolSuggestion(pending, toolResult) {
787
+ this.pendingUserInputs.delete(pending.toolUseId);
788
+ this.respondToServerRequest(pending.requestId, {
789
+ action: "accept",
790
+ content: null,
791
+ _meta: null,
792
+ });
793
+ this.emitMessage({
794
+ type: "permission_resolved",
795
+ toolUseId: pending.toolUseId,
796
+ });
797
+ this.emitToolResult(pending.toolUseId, toolResult);
798
+ if (this.pendingApprovals.size === 0 && this.pendingUserInputs.size === 0) {
799
+ this.setStatus("running");
800
+ }
801
+ }
680
802
  /**
681
803
  * Reject a pending user-input request (McpElicitation fallback).
682
804
  */
@@ -1402,10 +1524,13 @@ export class CodexProcess extends EventEmitter {
1402
1524
  case "mcpServer/elicitation/request": {
1403
1525
  const toolUseId = this.extractToolUseId(params, id);
1404
1526
  const elicitation = createElicitationInput(params);
1527
+ const toolName = elicitation.kind === "tool_suggestion"
1528
+ ? "ToolSuggestion"
1529
+ : "McpElicitation";
1405
1530
  this.pendingUserInputs.set(toolUseId, {
1406
1531
  requestId: id,
1407
1532
  toolUseId,
1408
- toolName: "McpElicitation",
1533
+ toolName,
1409
1534
  questions: elicitation.questions,
1410
1535
  input: elicitation.input,
1411
1536
  kind: elicitation.kind,
@@ -1413,15 +1538,23 @@ export class CodexProcess extends EventEmitter {
1413
1538
  this.emitMessage({
1414
1539
  type: "permission_request",
1415
1540
  toolUseId,
1416
- toolName: "McpElicitation",
1541
+ toolName,
1417
1542
  input: elicitation.input,
1418
1543
  });
1419
1544
  this.setStatus("waiting_approval");
1420
1545
  break;
1421
1546
  }
1422
- default:
1423
- this.respondToServerRequest(id, {});
1547
+ case "currentTime/read": {
1548
+ this.respondToServerRequest(id, {
1549
+ currentTimeAt: Math.floor(Date.now() / 1000),
1550
+ });
1551
+ break;
1552
+ }
1553
+ default: {
1554
+ console.warn(`[codex-process] unsupported server request: ${method}`);
1555
+ this.respondToServerRequestError(id, -32601, `Unsupported server request: ${method}`);
1424
1556
  break;
1557
+ }
1425
1558
  }
1426
1559
  }
1427
1560
  handleNotification(method, params) {
@@ -1555,6 +1688,41 @@ export class CodexProcess extends EventEmitter {
1555
1688
  this.handleServerRequestResolved(params);
1556
1689
  break;
1557
1690
  }
1691
+ case "warning":
1692
+ case "guardianWarning": {
1693
+ const message = stringValue(params.message);
1694
+ if (message) {
1695
+ this.emitMessage({
1696
+ type: "error",
1697
+ errorCode: "codex_warning",
1698
+ message,
1699
+ });
1700
+ }
1701
+ break;
1702
+ }
1703
+ case "configWarning":
1704
+ case "deprecationNotice": {
1705
+ const summary = stringValue(params.summary);
1706
+ const details = stringValue(params.details);
1707
+ if (summary || details) {
1708
+ this.emitMessage({
1709
+ type: "error",
1710
+ errorCode: "codex_warning",
1711
+ message: [summary, details].filter(Boolean).join("\n"),
1712
+ });
1713
+ }
1714
+ break;
1715
+ }
1716
+ case "error": {
1717
+ const error = asRecord(params.error);
1718
+ const message = stringValue(error?.message) ?? "Codex runtime error";
1719
+ this.emitMessage({
1720
+ type: "error",
1721
+ errorCode: params.willRetry ? "codex_warning" : "codex_runtime_error",
1722
+ message: params.willRetry ? `${message}\nCodex will retry.` : message,
1723
+ });
1724
+ break;
1725
+ }
1558
1726
  default:
1559
1727
  break;
1560
1728
  }
@@ -1954,6 +2122,22 @@ export class CodexProcess extends EventEmitter {
1954
2122
  this.lastPlanItemText = planText;
1955
2123
  break;
1956
2124
  }
2125
+ case "exitedreviewmode": {
2126
+ const text = typeof item.review === "string" ? item.review : "";
2127
+ if (!text)
2128
+ break;
2129
+ this.lastResultText = text;
2130
+ this.emitMessage({
2131
+ type: "assistant",
2132
+ message: {
2133
+ id: itemId,
2134
+ role: "assistant",
2135
+ content: [{ type: "text", text }],
2136
+ model: this.getMessageModel(),
2137
+ },
2138
+ });
2139
+ break;
2140
+ }
1957
2141
  case "error": {
1958
2142
  const message = typeof item.message === "string" ? item.message : "Codex item error";
1959
2143
  this.emitMessage({ type: "error", message });
@@ -2039,6 +2223,16 @@ export class CodexProcess extends EventEmitter {
2039
2223
  }
2040
2224
  }
2041
2225
  }
2226
+ respondToServerRequestError(id, code, message) {
2227
+ try {
2228
+ this.writeEnvelope({ id, error: { code, message } });
2229
+ }
2230
+ catch (err) {
2231
+ if (!this.stopped) {
2232
+ console.warn(`[codex-process] failed to reject server request: ${err instanceof Error ? err.message : String(err)}`);
2233
+ }
2234
+ }
2235
+ }
2042
2236
  writeEnvelope(envelope) {
2043
2237
  if (!this.transport || !this.transport.isRunning) {
2044
2238
  throw new Error("codex app-server is not running");
@@ -2753,10 +2947,8 @@ function parseResultObject(rawResult) {
2753
2947
  }
2754
2948
  function normalizeAnswerValues(value) {
2755
2949
  if (typeof value === "string") {
2756
- return value
2757
- .split(",")
2758
- .map((part) => part.trim())
2759
- .filter((part) => part.length > 0);
2950
+ const normalized = value.trim();
2951
+ return normalized ? [normalized] : [];
2760
2952
  }
2761
2953
  if (Array.isArray(value)) {
2762
2954
  return value
@@ -2777,6 +2969,13 @@ function normalizeAnswerValues(value) {
2777
2969
  return normalized ? [normalized] : [];
2778
2970
  }
2779
2971
  function buildElicitationResponse(pending, rawResult) {
2972
+ if (pending.kind === "tool_suggestion") {
2973
+ return {
2974
+ action: parseElicitationAction(rawResult),
2975
+ content: null,
2976
+ _meta: null,
2977
+ };
2978
+ }
2780
2979
  if (pending.kind === "elicitation_url") {
2781
2980
  const action = parseElicitationAction(rawResult);
2782
2981
  return {
@@ -2790,23 +2989,20 @@ function buildElicitationResponse(pending, rawResult) {
2790
2989
  }
2791
2990
  const parsed = parseResultObject(rawResult);
2792
2991
  const content = {};
2992
+ const schema = asRecord(pending.input.requestedSchema);
2993
+ const properties = asRecord(schema?.properties) ?? {};
2793
2994
  for (const question of pending.questions) {
2794
2995
  const candidate = parsed.byId[question.id] ?? parsed.byQuestion[question.question];
2795
- const answers = normalizeAnswerValues(candidate);
2796
- if (answers.length === 1) {
2797
- content[question.id] = answers[0];
2798
- }
2799
- else if (answers.length > 1) {
2800
- content[question.id] = answers;
2996
+ const value = coerceElicitationValue(candidate, asRecord(properties[question.id]));
2997
+ if (value !== undefined) {
2998
+ content[question.id] = value;
2801
2999
  }
2802
3000
  }
2803
3001
  if (Object.keys(content).length === 0 && pending.questions.length === 1) {
2804
- const answers = normalizeAnswerValues(rawResult);
2805
- if (answers.length === 1) {
2806
- content[pending.questions[0].id] = answers[0];
2807
- }
2808
- else if (answers.length > 1) {
2809
- content[pending.questions[0].id] = answers;
3002
+ const questionId = pending.questions[0].id;
3003
+ const value = coerceElicitationValue(rawResult, asRecord(properties[questionId]));
3004
+ if (value !== undefined) {
3005
+ content[questionId] = value;
2810
3006
  }
2811
3007
  }
2812
3008
  return {
@@ -2815,6 +3011,48 @@ function buildElicitationResponse(pending, rawResult) {
2815
3011
  _meta: null,
2816
3012
  };
2817
3013
  }
3014
+ function coerceElicitationValue(value, field) {
3015
+ if (value == null)
3016
+ return undefined;
3017
+ const type = stringValue(field?.type) ?? "string";
3018
+ if (type === "array") {
3019
+ if (Array.isArray(value)) {
3020
+ const entries = value.map((entry) => String(entry));
3021
+ return entries.length > 0 ? entries : undefined;
3022
+ }
3023
+ if (typeof value === "string") {
3024
+ return value
3025
+ .split(",")
3026
+ .map((entry) => entry.trim())
3027
+ .filter(Boolean);
3028
+ }
3029
+ return [String(value)];
3030
+ }
3031
+ const scalar = Array.isArray(value) ? value[0] : value;
3032
+ if (scalar == null)
3033
+ return undefined;
3034
+ if (typeof scalar === "string" && scalar.trim().length === 0) {
3035
+ return undefined;
3036
+ }
3037
+ if (type === "boolean") {
3038
+ if (typeof scalar === "boolean")
3039
+ return scalar;
3040
+ if (String(scalar).toLowerCase() === "true")
3041
+ return true;
3042
+ if (String(scalar).toLowerCase() === "false")
3043
+ return false;
3044
+ return undefined;
3045
+ }
3046
+ if (type === "number" || type === "integer") {
3047
+ const number = typeof scalar === "number" ? scalar : Number(scalar);
3048
+ if (!Number.isFinite(number))
3049
+ return undefined;
3050
+ if (type === "integer" && !Number.isInteger(number))
3051
+ return undefined;
3052
+ return number;
3053
+ }
3054
+ return String(scalar);
3055
+ }
2818
3056
  function buildApprovalElicitationResponse(pending, rawResult) {
2819
3057
  const selection = resolveApprovalElicitationSelection(pending, rawResult);
2820
3058
  const normalized = selection.trim().toLowerCase();
@@ -2908,7 +3146,32 @@ function createElicitationInput(params) {
2908
3146
  }
2909
3147
  const schema = asRecord(params.requestedSchema);
2910
3148
  const elicitationMeta = asRecord(params._meta);
2911
- if (isApprovalActionElicitation(schema, elicitationMeta)) {
3149
+ if (isToolSuggestionElicitation(serverName, elicitationMeta)) {
3150
+ const toolName = stringValue(elicitationMeta?.tool_name) ?? "Tool";
3151
+ return {
3152
+ kind: "tool_suggestion",
3153
+ questions: [],
3154
+ input: {
3155
+ mode: "form",
3156
+ serverName,
3157
+ message,
3158
+ _meta: elicitationMeta ?? null,
3159
+ toolType: stringValue(elicitationMeta?.tool_type),
3160
+ suggestType: stringValue(elicitationMeta?.suggest_type),
3161
+ suggestReason: stringValue(elicitationMeta?.suggest_reason) ?? message,
3162
+ toolId: stringValue(elicitationMeta?.tool_id),
3163
+ toolName,
3164
+ installUrl: stringValue(elicitationMeta?.install_url),
3165
+ remotePluginId: stringValue(elicitationMeta?.remote_plugin_id),
3166
+ appConnectorIds: Array.isArray(elicitationMeta?.app_connector_ids)
3167
+ ? elicitationMeta.app_connector_ids.filter((entry) => typeof entry === "string")
3168
+ : [],
3169
+ installState: "idle",
3170
+ appsNeedingAuth: [],
3171
+ },
3172
+ };
3173
+ }
3174
+ if (isApprovalActionElicitation(schema, serverName, elicitationMeta)) {
2912
3175
  const questionId = "approval";
2913
3176
  const isToolApproval = isToolApprovalElicitation(elicitationMeta);
2914
3177
  return {
@@ -2944,26 +3207,15 @@ function createElicitationInput(params) {
2944
3207
  const field = value;
2945
3208
  const title = typeof field.title === "string" ? field.title : key;
2946
3209
  const description = typeof field.description === "string" ? field.description : message;
2947
- const enumValues = Array.isArray(field.enum)
2948
- ? field.enum.map((entry) => String(entry))
2949
- : [];
2950
3210
  const type = typeof field.type === "string" ? field.type : "";
2951
- const options = enumValues.length > 0
2952
- ? enumValues.map((entry, index) => ({
2953
- label: entry,
2954
- description: index === 0 ? description : "",
2955
- }))
2956
- : type === "boolean"
2957
- ? [
2958
- { label: "true", description: description },
2959
- { label: "false", description: "" },
2960
- ]
2961
- : [];
3211
+ const options = buildElicitationFieldOptions(field, description);
2962
3212
  return {
2963
3213
  id: key,
2964
3214
  question: requiredFields.has(key) ? `${title} (required)` : title,
2965
3215
  header: serverName,
2966
3216
  options,
3217
+ required: requiredFields.has(key),
3218
+ multiSelect: type === "array",
2967
3219
  isOther: options.length === 0,
2968
3220
  isSecret: false,
2969
3221
  };
@@ -2976,6 +3228,8 @@ function createElicitationInput(params) {
2976
3228
  question: message,
2977
3229
  header: serverName,
2978
3230
  options: [],
3231
+ required: true,
3232
+ multiSelect: false,
2979
3233
  isOther: true,
2980
3234
  isSecret: false,
2981
3235
  },
@@ -2985,6 +3239,7 @@ function createElicitationInput(params) {
2985
3239
  questions: normalizedQuestions.map((question) => ({
2986
3240
  id: question.id,
2987
3241
  question: question.question,
3242
+ required: question.required,
2988
3243
  })),
2989
3244
  input: {
2990
3245
  mode: "form",
@@ -2997,15 +3252,58 @@ function createElicitationInput(params) {
2997
3252
  header: question.header,
2998
3253
  question: question.question,
2999
3254
  options: question.options,
3000
- multiSelect: false,
3255
+ required: question.required,
3256
+ multiSelect: question.multiSelect,
3001
3257
  isOther: question.isOther,
3002
3258
  isSecret: question.isSecret,
3003
3259
  })),
3004
3260
  },
3005
3261
  };
3006
3262
  }
3007
- function isApprovalActionElicitation(schema, meta) {
3008
- return isEmptyObjectSchema(schema) && !isToolSuggestionElicitation(meta);
3263
+ function buildElicitationFieldOptions(field, description) {
3264
+ const type = stringValue(field.type);
3265
+ const source = type === "array" ? asRecord(field.items) ?? {} : field;
3266
+ const rawOptions = Array.isArray(source.oneOf)
3267
+ ? source.oneOf
3268
+ : Array.isArray(source.anyOf)
3269
+ ? source.anyOf
3270
+ : null;
3271
+ if (rawOptions) {
3272
+ return rawOptions.flatMap((entry, index) => {
3273
+ const option = asRecord(entry);
3274
+ const value = stringValue(option?.const);
3275
+ if (!value)
3276
+ return [];
3277
+ return [
3278
+ {
3279
+ label: stringValue(option?.title) ?? value,
3280
+ value,
3281
+ description: index === 0 ? description : "",
3282
+ },
3283
+ ];
3284
+ });
3285
+ }
3286
+ if (Array.isArray(source.enum)) {
3287
+ return source.enum.map((entry, index) => {
3288
+ const value = String(entry);
3289
+ return {
3290
+ label: value,
3291
+ value,
3292
+ description: index === 0 ? description : "",
3293
+ };
3294
+ });
3295
+ }
3296
+ if (type === "boolean") {
3297
+ return [
3298
+ { label: "true", value: "true", description },
3299
+ { label: "false", value: "false", description: "" },
3300
+ ];
3301
+ }
3302
+ return [];
3303
+ }
3304
+ function isApprovalActionElicitation(schema, serverName, meta) {
3305
+ return (isEmptyObjectSchema(schema) &&
3306
+ !isToolSuggestionElicitation(serverName, meta));
3009
3307
  }
3010
3308
  function isEmptyObjectSchema(schema) {
3011
3309
  if (!schema)
@@ -3018,8 +3316,9 @@ function isEmptyObjectSchema(schema) {
3018
3316
  function isToolApprovalElicitation(meta) {
3019
3317
  return meta?.codex_approval_kind === "mcp_tool_call";
3020
3318
  }
3021
- function isToolSuggestionElicitation(meta) {
3022
- return meta?.codex_approval_kind === "tool_suggestion";
3319
+ function isToolSuggestionElicitation(serverName, meta) {
3320
+ return (serverName === "codex_apps" &&
3321
+ meta?.codex_approval_kind === "tool_suggestion");
3023
3322
  }
3024
3323
  function buildApprovalActionElicitationOptions(meta, isToolApproval) {
3025
3324
  const persistModes = extractPersistModes(meta);
@@ -3108,6 +3407,32 @@ function asRecord(value) {
3108
3407
  ? value
3109
3408
  : undefined;
3110
3409
  }
3410
+ function stringValue(value) {
3411
+ return typeof value === "string" && value.length > 0 ? value : undefined;
3412
+ }
3413
+ function normalizeToolSuggestionApps(value) {
3414
+ if (!Array.isArray(value))
3415
+ return [];
3416
+ return value.flatMap((entry) => {
3417
+ const app = asRecord(entry);
3418
+ const id = stringValue(app?.id);
3419
+ const name = stringValue(app?.name);
3420
+ if (!id || !name)
3421
+ return [];
3422
+ const description = stringValue(app?.description);
3423
+ const installUrl = stringValue(app?.installUrl);
3424
+ const category = stringValue(app?.category);
3425
+ return [
3426
+ {
3427
+ id,
3428
+ name,
3429
+ ...(description ? { description } : {}),
3430
+ ...(installUrl ? { installUrl } : {}),
3431
+ ...(category ? { category } : {}),
3432
+ },
3433
+ ];
3434
+ });
3435
+ }
3111
3436
  function buildPlanUpdateToolUseInput(params) {
3112
3437
  const stepsRaw = params.plan;
3113
3438
  if (!Array.isArray(stepsRaw) || stepsRaw.length === 0)