@love-moon/conductor-cli 0.13.3 → 0.15.0

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/src/daemon.js CHANGED
@@ -40,9 +40,11 @@ import {
40
40
  } from "./custom-command-handlers.js";
41
41
  import {
42
42
  REMOTE_EXEC_CAPABILITY,
43
+ REMOTE_EXEC_RUN_ID_CAPABILITY,
43
44
  createRemoteExecHandlers,
44
45
  handleRemoteExecRequest,
45
46
  } from "./remote-exec-handlers.js";
47
+ import { REMOTE_WORKTREE_ENV, remoteWorktreeFireEnv } from "./remote/mcp-launch.js";
46
48
  import {
47
49
  REMOTE_FILE_CAPABILITY,
48
50
  createRemoteFileHandlers,
@@ -703,7 +705,9 @@ export function buildFireSpawnArgs({ selectedBackend, initialContent, launchConf
703
705
  const prefill = typeof initialContent === "string" ? initialContent : "";
704
706
  const effectivePrefill = prefill || goalObjective;
705
707
  if (effectivePrefill) {
706
- args.push("--prefill", effectivePrefill);
708
+ // `=` form: as a separate argv entry, yargs reads content that starts with
709
+ // "-" (a markdown list, "--model ...") as a new flag and drops it.
710
+ args.push(`--prefill=${effectivePrefill}`);
707
711
  }
708
712
  const resumeSessionId =
709
713
  normalizeOptionalString(launchConfig?.resumeSessionId) ||
@@ -789,6 +793,10 @@ export async function collectBackendSessions({
789
793
  cwd: normalizeOptionalString(entry?.cwd),
790
794
  title: normalizeOptionalString(entry?.title),
791
795
  updated_at: Number.isFinite(entry?.updatedAt) ? new Date(entry.updatedAt).toISOString() : null,
796
+ first_user_message: normalizeOptionalString(entry?.preview?.firstUserMessage),
797
+ first_reply: normalizeOptionalString(entry?.preview?.firstReply),
798
+ last_message: normalizeOptionalString(entry?.preview?.lastMessage?.text),
799
+ last_message_role: normalizeOptionalString(entry?.preview?.lastMessage?.role),
792
800
  });
793
801
  }
794
802
  } catch (error) {
@@ -889,6 +897,7 @@ const PTY_TASK_SCOPED_ENV_KEYS = [
889
897
  "CONDUCTOR_PTY_SESSION_ID",
890
898
  "CONDUCTOR_LAUNCHED_BY_DAEMON",
891
899
  "CONDUCTOR_RESUME_CWD",
900
+ REMOTE_WORKTREE_ENV,
892
901
  ];
893
902
 
894
903
  // Task-scoped env vars a Fire must NEVER inherit from anywhere but this
@@ -3883,14 +3892,19 @@ export function startDaemon(config = {}, deps = {}) {
3883
3892
  "project_agents_registry",
3884
3893
  "restart_daemon",
3885
3894
  "refresh_session_inplace",
3895
+ "restart_first_message",
3886
3896
  "persistent_round_v1",
3887
3897
  "task_attachments_v1",
3898
+ // RFC 0041: this CLI can run a global-backend task's AI here — it drives
3899
+ // another daemon over `conductor remote` and binds the remote_* MCP tools
3900
+ // to a remoteWorkspace as well as a remoteWorktree.
3901
+ "global_backend_v1",
3888
3902
  "backend_session_list",
3889
3903
  CUSTOM_COMMANDS_CAPABILITY,
3890
3904
  UPDATE_DAEMON_CAPABILITY,
3891
3905
  ];
3892
3906
  if (remoteExecEnabled) {
3893
- advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY);
3907
+ advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY, REMOTE_EXEC_RUN_ID_CAPABILITY);
3894
3908
  } else {
3895
3909
  log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
3896
3910
  }
@@ -5780,6 +5794,9 @@ export function startDaemon(config = {}, deps = {}) {
5780
5794
  let taskDir =
5781
5795
  normalizeOptionalString(launchConfig.cwd) ||
5782
5796
  boundPath;
5797
+ // No project path: the shell starts in HOME; the scratch dir below only
5798
+ // holds the terminal log.
5799
+ const shellCwd = taskDir || homeDir;
5783
5800
  if (!taskDir) {
5784
5801
  const now = new Date();
5785
5802
  const dayDir = path.join(WORKSPACE_ROOT, formatWorkspaceDate(now));
@@ -5805,7 +5822,7 @@ export function startDaemon(config = {}, deps = {}) {
5805
5822
 
5806
5823
  let launchSpec;
5807
5824
  try {
5808
- launchSpec = resolvePtyLaunchSpec(launchConfig, taskDir);
5825
+ launchSpec = resolvePtyLaunchSpec(launchConfig, shellCwd);
5809
5826
  } catch (error) {
5810
5827
  logError(`Failed to resolve PTY launch config for ${taskId}: ${error?.message || error}`);
5811
5828
  sendAgentCommandAck({
@@ -5834,7 +5851,7 @@ export function startDaemon(config = {}, deps = {}) {
5834
5851
 
5835
5852
  const env = buildPtyTaskEnv(process.env, launchSpec.env);
5836
5853
 
5837
- const logPath = path.join(launchSpec.cwd, "conductor-terminal.log");
5854
+ const logPath = path.join(taskDir, "conductor-terminal.log");
5838
5855
  let logStream;
5839
5856
  try {
5840
5857
  logStream = createWriteStreamFn(logPath, { flags: "a" });
@@ -5870,7 +5887,6 @@ export function startDaemon(config = {}, deps = {}) {
5870
5887
  rejectCreatePtyTaskDuringShutdown(payload, { sendAck: false });
5871
5888
  return;
5872
5889
  }
5873
- const resolvedLogPath = path.join(taskDir, "conductor-terminal.log");
5874
5890
 
5875
5891
  const startedAt = new Date().toISOString();
5876
5892
  const record = {
@@ -5879,7 +5895,8 @@ export function startDaemon(config = {}, deps = {}) {
5879
5895
  ptySessionId,
5880
5896
  projectId,
5881
5897
  taskDir,
5882
- logPath: resolvedLogPath,
5898
+ cwd: launchSpec.cwd,
5899
+ logPath,
5883
5900
  logStream,
5884
5901
  cols: launchSpec.cols,
5885
5902
  rows: launchSpec.rows,
@@ -5900,7 +5917,7 @@ export function startDaemon(config = {}, deps = {}) {
5900
5917
  project_id: projectId,
5901
5918
  pty_session_id: ptySessionId,
5902
5919
  pid: Number.isInteger(pty?.pid) ? pty.pid : null,
5903
- cwd: taskDir,
5920
+ cwd: launchSpec.cwd,
5904
5921
  shell: launchSpec.shell,
5905
5922
  cols: launchSpec.cols,
5906
5923
  rows: launchSpec.rows,
@@ -6107,7 +6124,7 @@ export function startDaemon(config = {}, deps = {}) {
6107
6124
  project_id: record.projectId,
6108
6125
  pty_session_id: record.ptySessionId,
6109
6126
  pid: Number.isInteger(record.pty?.pid) ? record.pty.pid : null,
6110
- cwd: record.taskDir,
6127
+ cwd: record.cwd,
6111
6128
  shell: record.shell,
6112
6129
  cols: record.cols,
6113
6130
  rows: record.rows,
@@ -7808,6 +7825,8 @@ export function startDaemon(config = {}, deps = {}) {
7808
7825
  // backend (dsh/copilot) would see e.g. `claude --model opus` here and
7809
7826
  // mis-apply its flags. Fire treats "" as unset.
7810
7827
  CONDUCTOR_CLI_COMMAND: cliCommand,
7828
+ // RFC 0040: lets fire give the AI tools bound to the remote worktree.
7829
+ ...remoteWorktreeFireEnv(launchConfig),
7811
7830
  };
7812
7831
  if (AGENT_TOKEN) {
7813
7832
  env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
@@ -8101,6 +8120,7 @@ export function startDaemon(config = {}, deps = {}) {
8101
8120
  source_session_file_path: sourceSessionFilePath,
8102
8121
  target_backend_type: targetBackendType,
8103
8122
  resume_context_url: resumeContextUrlRaw,
8123
+ initial_content: initialContentRaw,
8104
8124
  request_id: requestIdRaw,
8105
8125
  } = payload || {};
8106
8126
 
@@ -8538,9 +8558,15 @@ export function startDaemon(config = {}, deps = {}) {
8538
8558
  if (isForkMode) {
8539
8559
  // Fork mode starts a brand-new session on the target backend; no --resume.
8540
8560
  // The prior conversation is delivered as a plain-text URL via the prompt
8541
- // so the target backend fetches it on its own.
8542
- args.push("--");
8543
- args.push(resumeHandoffPrompt);
8561
+ // so the target backend fetches it on its own — unless the user set their
8562
+ // own first message, which replaces it. That one goes through --prefill
8563
+ // (string-typed) so fire does not coerce a numeric message like "0.10".
8564
+ const customFirstMessage = normalizeOptionalString(initialContentRaw);
8565
+ if (customFirstMessage) {
8566
+ args.push(`--prefill=${customFirstMessage}`, "--");
8567
+ } else {
8568
+ args.push("--", resumeHandoffPrompt);
8569
+ }
8544
8570
  } else {
8545
8571
  if (shouldResumeSession) {
8546
8572
  args.push("--resume", resolvedResumeSessionId);
@@ -8560,6 +8586,7 @@ export function startDaemon(config = {}, deps = {}) {
8560
8586
  // gets an explicit `-e CONDUCTOR_CLI_COMMAND=` that overrides any stale
8561
8587
  // value in the tmux SERVER's global environment (see create_task).
8562
8588
  CONDUCTOR_CLI_COMMAND: cliCommand,
8589
+ ...remoteWorktreeFireEnv(targetLaunchConfig),
8563
8590
  };
8564
8591
  env.CONDUCTOR_RESUME_CWD = resolvedResumeCwd;
8565
8592
  if (AGENT_TOKEN) {
@@ -112,7 +112,7 @@ export async function withRetry(fn, {
112
112
  } catch (error) {
113
113
  lastError = error;
114
114
  // An aborted transfer is the caller's own deadline firing, not a blip.
115
- if (error?.name === "AbortError" || !retryable(error)) throw error;
115
+ if (error?.name === "AbortError" || !(await retryable(error))) throw error;
116
116
  if (attempt === attempts - 1) break;
117
117
  await sleep(baseDelayMs * 2 ** attempt);
118
118
  }
package/src/remote/cp.js CHANGED
@@ -385,7 +385,7 @@ async function downloadChunks({
385
385
  }, { sleep });
386
386
  }
387
387
 
388
- async function uploadFile(ctx) {
388
+ export async function uploadFile(ctx) {
389
389
  const { config, target, localPath, remotePath, fetchImpl, consoleImpl, signal, quiet } = ctx;
390
390
  const resolvedLocal = path.resolve(expandHome(localPath));
391
391
 
@@ -466,7 +466,7 @@ async function uploadFile(ctx) {
466
466
  return { ...delivered, transferId, direction: "up", sizeBytes: stat.size, sha256 };
467
467
  }
468
468
 
469
- async function downloadFile(ctx) {
469
+ export async function downloadFile(ctx) {
470
470
  const { config, target, remotePath, localPath, fetchImpl, consoleImpl, signal, quiet } = ctx;
471
471
  const basePath = `/api/agents/${encodeURIComponent(target)}/files`;
472
472
 
@@ -6,6 +6,7 @@
6
6
  * or globs are actually wanted.
7
7
  */
8
8
 
9
+ import { randomUUID } from "node:crypto";
9
10
  import process from "node:process";
10
11
 
11
12
  import {
@@ -13,6 +14,7 @@ import {
13
14
  UsageError,
14
15
  callApi,
15
16
  delay,
17
+ isRetryable,
16
18
  loadCliConfig,
17
19
  parseTimeoutMs as parseSharedTimeoutMs,
18
20
  withRetry,
@@ -25,12 +27,10 @@ const POLL_INTERVAL_MS = 1_000;
25
27
  /** How long a single request may block server-side before we switch to polling. */
26
28
  const POST_WAIT_MS = 10_000;
27
29
  /**
28
- * A 429 means the per-user in-flight cap (8) is full — nothing was started, so
29
- * retrying is safe. Six attempts at 500ms doubling waits out ~15s, longer than
30
- * one saturating POST can block (POST_WAIT_MS). Nothing else is retried here:
31
- * a 5xx may have started the command already, and a second POST would run it twice.
30
+ * Six attempts at 500ms doubling wait out ~15s, longer than one saturating POST
31
+ * can block (POST_WAIT_MS). What is safe to retry is decided in `execRemote`.
32
32
  */
33
- const POST_RETRY = { attempts: 6, retryable: (error) => error?.status === 429 };
33
+ const POST_ATTEMPTS = 6;
34
34
  const INTERRUPT_SIGNALS = ["SIGINT", "SIGTERM"];
35
35
 
36
36
  const VALUE_FLAGS = new Map([
@@ -233,16 +233,31 @@ export async function execRemote(config, target, command, options = {}) {
233
233
  const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
234
234
  const deadline = now() + timeoutMs;
235
235
 
236
+ // Sent on every attempt: a daemon that already started this run returns it
237
+ // instead of running the command again.
238
+ const runId = randomUUID();
239
+ let dedupes;
240
+ const retryable = async (error) => {
241
+ // The per-user in-flight cap (8) is full: nothing was started.
242
+ if (error?.status === 429) return true;
243
+ // A 502 is the daemon's own answer (bad workspace, …); a retry gets the same one.
244
+ if (!isRetryable(error) || error.status === 502 || now() >= deadline) return false;
245
+ // A network error or other 5xx may have started the command already.
246
+ dedupes ??= await dedupesRunId(config, basePath, fetchImpl, sleep);
247
+ return dedupes;
248
+ };
249
+
236
250
  const started = await withRetry(() => callApi(config, "POST", basePath, {
237
251
  command,
238
252
  args,
253
+ runId,
239
254
  ...(workspace ? { workspace } : {}),
240
255
  ...(env && Object.keys(env).length > 0 ? { env } : {}),
241
256
  // Deliberately short, and independent of the overall deadline: that is
242
257
  // owned by the poll loop below. Handing the daemon the full budget would
243
258
  // make one HTTP request block for it and leave the loop unreachable.
244
259
  timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
245
- }, fetchImpl), { ...POST_RETRY, sleep });
260
+ }, fetchImpl), { attempts: POST_ATTEMPTS, retryable, sleep });
246
261
 
247
262
  const waited = await waitForRun(config, basePath, started, {
248
263
  deadline, fetchImpl, sleep, now, killOnTimeout, signal,
@@ -250,6 +265,21 @@ export async function execRemote(config, target, command, options = {}) {
250
265
  return { ...waited, basePath };
251
266
  }
252
267
 
268
+ /**
269
+ * Whether the target dedupes exec on `runId`, which is what makes re-sending a
270
+ * POST that failed mid-flight safe. Asked only after such a failure, so the happy
271
+ * path pays nothing. Anything short of a clear yes — an old server answers 405,
272
+ * an old daemon `false` — keeps the POST to 429-only retries.
273
+ */
274
+ async function dedupesRunId(config, basePath, fetchImpl, sleep) {
275
+ try {
276
+ const answer = await withRetry(() => callApi(config, "GET", basePath, null, fetchImpl), { sleep });
277
+ return answer?.dedupesRunId === true;
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
253
283
  /**
254
284
  * `execRemote` for callers that only care whether it worked. Throws with the
255
285
  * remote's own stderr, which is what the user needs to see.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Wiring `conductor remote mcp` into a remote-worktree task (RFC 0040).
3
+ *
4
+ * The daemon knows the task's `launch_config.remoteWorktree`; fire knows which
5
+ * AI backend it is about to start. The daemon resolves the binding (which
6
+ * daemon, which directory) and hands it to fire in one env var; fire turns it
7
+ * into that backend's MCP configuration. Kept free of heavy imports: both the
8
+ * daemon and fire load it on every task.
9
+ */
10
+
11
+ import path from "node:path";
12
+ import process from "node:process";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ export const MCP_SERVER_NAME = "conductor_remote";
16
+ export const REMOTE_WORKTREE_ENV = "CONDUCTOR_REMOTE_WORKTREE";
17
+
18
+ /** The server's credentials travel by env, never argv (argv is world-readable in `ps`). */
19
+ const FORWARDED_ENV = [
20
+ "CONDUCTOR_AGENT_TOKEN",
21
+ "CONDUCTOR_BACKEND_URL",
22
+ "CONDUCTOR_CONFIG",
23
+ "CONDUCTOR_HOME",
24
+ "CONDUCTOR_REMOTE_CHUNK_BYTES",
25
+ // Codex hands MCP servers only a minimal env; a corporate CA must still reach fetch.
26
+ "NODE_EXTRA_CA_CERTS",
27
+ ];
28
+
29
+ /** remote_bash may wait up to 10 minutes; Codex's default MCP tool timeout is 60s. */
30
+ const CODEX_TOOL_TIMEOUT_SEC = 900;
31
+
32
+ const REMOTE_SCRIPT = fileURLToPath(new URL("../../bin/conductor-remote.js", import.meta.url));
33
+
34
+ const str = (value) => (typeof value === "string" && value.trim() ? value.trim() : "");
35
+
36
+ const isWindowsStylePath = (value) => /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\");
37
+
38
+ /**
39
+ * Where the remote worktree lives, from `launch_config.remoteWorktree`. Same
40
+ * math as web's `resolveRemoteWorktreePaths` and the daemon's
41
+ * `buildTaskWorktreeRoot`: `<workspacePath>/.conductor/worktrees/<branch>`, and
42
+ * the work dir is the project's offset inside the repository under that.
43
+ */
44
+ export function resolveRemoteWorktreeBinding(launchConfig) {
45
+ const raw = launchConfig?.remoteWorktree ?? launchConfig?.remote_worktree;
46
+ if (!raw || typeof raw !== "object") return resolveRemoteWorkspaceBinding(launchConfig);
47
+ const host = str(raw.host);
48
+ const repoRoot = str(raw.repoRoot) || str(raw.repo_root);
49
+ const workspacePath = str(raw.workspacePath) || str(raw.workspace_path);
50
+ const branch = str(raw.branch);
51
+ if (!host || !repoRoot || !workspacePath || !branch) return null;
52
+
53
+ const pathApi = isWindowsStylePath(repoRoot) || isWindowsStylePath(workspacePath) ? path.win32 : path.posix;
54
+ const folder = branch.replace(/[/\\]/g, "_").replace(/\.\./g, "_");
55
+ const root = pathApi.join(workspacePath, ".conductor", "worktrees", folder);
56
+ const relative = pathApi.relative(repoRoot, workspacePath);
57
+ if (relative.startsWith("..") || pathApi.isAbsolute(relative)) return null;
58
+ const cwd = relative && relative !== "." ? pathApi.join(root, relative) : root;
59
+ return { host, root, cwd };
60
+ }
61
+
62
+ /**
63
+ * RFC 0041 direct mode: `launch_config.remoteWorkspace` points at the project
64
+ * directory itself (no worktree). The tools stay inside the repository and
65
+ * start in the project's directory.
66
+ */
67
+ function resolveRemoteWorkspaceBinding(launchConfig) {
68
+ const raw = launchConfig?.remoteWorkspace ?? launchConfig?.remote_workspace;
69
+ if (!raw || typeof raw !== "object") return null;
70
+ const host = str(raw.host);
71
+ const repoRoot = str(raw.repoRoot) || str(raw.repo_root);
72
+ const workspacePath = str(raw.workspacePath) || str(raw.workspace_path);
73
+ if (!host || !repoRoot || !workspacePath) return null;
74
+ const pathApi = isWindowsStylePath(repoRoot) || isWindowsStylePath(workspacePath) ? path.win32 : path.posix;
75
+ const relative = pathApi.relative(repoRoot, workspacePath);
76
+ if (relative.startsWith("..") || pathApi.isAbsolute(relative)) return null;
77
+ return { host, root: repoRoot, cwd: workspacePath };
78
+ }
79
+
80
+ /** Env the daemon adds when it spawns fire. Empty for an ordinary task. */
81
+ export function remoteWorktreeFireEnv(launchConfig) {
82
+ const binding = resolveRemoteWorktreeBinding(launchConfig);
83
+ return binding ? { [REMOTE_WORKTREE_ENV]: JSON.stringify(binding) } : {};
84
+ }
85
+
86
+ export function readRemoteWorktreeBinding(env = process.env) {
87
+ const raw = str(env?.[REMOTE_WORKTREE_ENV]);
88
+ if (!raw) return null;
89
+ try {
90
+ const parsed = JSON.parse(raw);
91
+ const host = str(parsed?.host);
92
+ const root = str(parsed?.root);
93
+ const cwd = str(parsed?.cwd) || root;
94
+ return host && root ? { host, root, cwd } : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function serverArgs(binding, configFile, remoteScript) {
101
+ return [
102
+ remoteScript,
103
+ "mcp",
104
+ "--host", binding.host,
105
+ "--root", binding.root,
106
+ "--cwd", binding.cwd,
107
+ // Absolute: the backend starts the server in the session's cwd, not ours.
108
+ ...(configFile ? ["--config-file", path.resolve(configFile)] : []),
109
+ ];
110
+ }
111
+
112
+ /**
113
+ * Session options that give the backend the remote workspace tools, merged
114
+ * over what the user configured. Only Claude and Codex are wired; any other
115
+ * backend gets nothing and keeps using `conductor remote exec` as before.
116
+ */
117
+ export function buildRemoteWorktreeSessionOptions({
118
+ backend,
119
+ binding,
120
+ sessionOptions = {},
121
+ commandLine = "",
122
+ configFile = "",
123
+ env = process.env,
124
+ execPath = process.execPath,
125
+ remoteScript = REMOTE_SCRIPT,
126
+ } = {}) {
127
+ if (!binding) return {};
128
+ const args = serverArgs(binding, configFile, remoteScript);
129
+ const normalizedBackend = String(backend || "").trim().toLowerCase();
130
+
131
+ if (normalizedBackend === "claude") {
132
+ const allowed = Array.isArray(sessionOptions.allowedTools) ? sessionOptions.allowedTools : [];
133
+ // Same precedence as the Claude session: explicit option, then the
134
+ // configured command line's `--permission-mode`.
135
+ const mode = sessionOptions.permissionMode
136
+ ?? String(commandLine || "").match(/--permission-mode[=\s]+([^\s"']+)/)?.[1];
137
+ return {
138
+ mcpServers: {
139
+ ...(sessionOptions.mcpServers || {}),
140
+ [MCP_SERVER_NAME]: { type: "stdio", command: execPath, args },
141
+ },
142
+ // Under the default bypassPermissions a root install is downgraded to
143
+ // acceptEdits, which would deny every remote_* call headless. A mode the
144
+ // user chose on purpose is left alone rather than widened.
145
+ ...(mode === undefined || mode === "bypassPermissions"
146
+ ? { allowedTools: [...new Set([...allowed, `mcp__${MCP_SERVER_NAME}`])] }
147
+ : {}),
148
+ };
149
+ }
150
+
151
+ if (normalizedBackend === "codex") {
152
+ // Values are TOML; JSON strings and string arrays are valid TOML as-is.
153
+ const prefix = `mcp_servers.${MCP_SERVER_NAME}`;
154
+ const forwarded = FORWARDED_ENV.filter((name) => str(env?.[name]));
155
+ return {
156
+ configOverrides: [
157
+ ...(Array.isArray(sessionOptions.configOverrides) ? sessionOptions.configOverrides : []),
158
+ `${prefix}.command=${JSON.stringify(execPath)}`,
159
+ `${prefix}.args=${JSON.stringify(args)}`,
160
+ ...(forwarded.length > 0 ? [`${prefix}.env_vars=${JSON.stringify(forwarded)}`] : []),
161
+ `${prefix}.tool_timeout_sec=${CODEX_TOOL_TIMEOUT_SEC}`,
162
+ ],
163
+ };
164
+ }
165
+
166
+ return {};
167
+ }