balladeer 1.0.13 → 1.0.15

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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Taking this product's own older, per-repository form out of a checkout once
3
+ * the user-scope form is in place, so one hook speaks per session.
4
+ *
5
+ * Releases before 1.0.8 registered Balladeer inside each repository: a hook in
6
+ * the checkout's `.claude/settings.json` (statusMessage "Balladeer current
7
+ * guidance (loader 1)"), a `balladeer` server in its `.mcp.json` that names the
8
+ * repository, and for Codex the fenced `# balladeer:guidance` and
9
+ * `# balladeer:mcp` blocks in `.codex/config.toml`. From 1.0.8 the same hook
10
+ * and server live at the host's user scope and run in every checkout. A laptop
11
+ * that moved from one to the other without cleaning up runs both: the session
12
+ * hears the guidance twice, and the server sees two hook forms fire for one
13
+ * person (found on Luke's laptop, 23 September 2026). Every removal here
14
+ * proves ownership from the entry itself; nothing else in those files moves.
15
+ * A file git tracks is edited without a copy (git holds the previous version)
16
+ * and the person is told to commit it; an untracked file is copied beside
17
+ * itself first.
18
+ */
19
+ export type CheckoutFormItem = Readonly<{
20
+ path: string;
21
+ found: string;
22
+ tracked: boolean;
23
+ remove: () => string | undefined;
24
+ }>;
25
+ export type CheckoutFormReport = Readonly<{
26
+ removed: readonly {
27
+ path: string;
28
+ found: string;
29
+ copy?: string;
30
+ tracked: boolean;
31
+ }[];
32
+ failed: readonly {
33
+ path: string;
34
+ found: string;
35
+ reason: string;
36
+ }[];
37
+ }>;
38
+ /** The older per-repository form in the checkout that holds `cwd`, if any. */
39
+ export declare function findCheckoutForm(input: {
40
+ cwd: string;
41
+ controlPlane: string;
42
+ now?: () => Date;
43
+ }): CheckoutFormItem[];
44
+ export declare function removeCheckoutForm(items: readonly CheckoutFormItem[]): CheckoutFormReport;
45
+ export declare function describeCheckoutFormRemoval(report: CheckoutFormReport): string[];
@@ -0,0 +1,223 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, lstatSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { isOurEntry, writeJsonAtomically } from "./mcp-config.js";
5
+ const PROJECT_OWNER = "Balladeer current guidance (loader 1)";
6
+ const CODEX_BLOCKS = [
7
+ ["# balladeer:guidance:start", "# balladeer:guidance:end"],
8
+ ["# balladeer:mcp:start", "# balladeer:mcp:end"],
9
+ ];
10
+ function repositoryRoot(cwd) {
11
+ try {
12
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
13
+ cwd,
14
+ encoding: "utf8",
15
+ stdio: ["ignore", "pipe", "ignore"],
16
+ timeout: 2_000,
17
+ }).trim();
18
+ return root.length > 0 ? root : undefined;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ function tracked(root, path) {
25
+ try {
26
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", path], {
27
+ cwd: root,
28
+ stdio: "ignore",
29
+ timeout: 2_000,
30
+ });
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ function regularFile(path) {
38
+ try {
39
+ return lstatSync(path).isFile();
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ function readJson(path) {
46
+ try {
47
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
48
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
49
+ ? parsed
50
+ : undefined;
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
56
+ function backup(path, now) {
57
+ // A file name, not a sentence: the person reads the copy's path back, and the
58
+ // stamp only has to sort. Same form as the earlier-client backups.
59
+ const stamp = now().toISOString().replaceAll(/[:.]/g, "-");
60
+ const copy = `${path}.before-balladeer-${stamp}`;
61
+ copyFileSync(path, copy);
62
+ return copy;
63
+ }
64
+ const isProjectHook = (hook) => {
65
+ if (hook === null || typeof hook !== "object")
66
+ return false;
67
+ const { command, statusMessage } = hook;
68
+ if (statusMessage === PROJECT_OWNER)
69
+ return true;
70
+ return (typeof command === "string" &&
71
+ /balladeer/i.test(command) &&
72
+ /--hook (?:claude|codex)\b/.test(command) &&
73
+ /--repository\b/.test(command));
74
+ };
75
+ function claudeHooks(root, now) {
76
+ const path = join(root, ".claude", "settings.json");
77
+ if (!regularFile(path))
78
+ return [];
79
+ const settings = readJson(path);
80
+ const hooks = settings?.hooks;
81
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
82
+ return [];
83
+ const events = Object.entries(hooks).filter(([, groups]) => Array.isArray(groups) &&
84
+ groups.some((group) => (group?.hooks ?? []).some((h) => isProjectHook(h))));
85
+ if (events.length === 0)
86
+ return [];
87
+ const isTracked = tracked(root, ".claude/settings.json");
88
+ return [
89
+ {
90
+ path,
91
+ tracked: isTracked,
92
+ found: `per-repository hook${events.length === 1 ? "" : "s"} (${events.map(([event]) => event).join(", ")})`,
93
+ remove: () => {
94
+ const copy = isTracked ? undefined : backup(path, now);
95
+ const current = readJson(path) ?? {};
96
+ const kept = {};
97
+ for (const [event, groups] of Object.entries((current.hooks ?? {}))) {
98
+ if (!Array.isArray(groups)) {
99
+ kept[event] = groups;
100
+ continue;
101
+ }
102
+ const left = groups.filter((group) => !(group?.hooks ?? []).some((h) => isProjectHook(h)));
103
+ if (left.length > 0)
104
+ kept[event] = left;
105
+ }
106
+ writeJsonAtomically(path, JSON.stringify({ ...current, hooks: kept }, null, 2) + "\n");
107
+ return copy;
108
+ },
109
+ },
110
+ ];
111
+ }
112
+ function claudeServer(root, controlPlane, now) {
113
+ const path = join(root, ".mcp.json");
114
+ if (!regularFile(path))
115
+ return [];
116
+ const config = readJson(path);
117
+ const servers = config?.mcpServers;
118
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
119
+ return [];
120
+ const entry = servers.balladeer;
121
+ if (entry === undefined || !isOurEntry(entry, controlPlane))
122
+ return [];
123
+ const args = entry.args;
124
+ if (!Array.isArray(args) || !args.includes("--repository"))
125
+ return [];
126
+ const isTracked = tracked(root, ".mcp.json");
127
+ return [
128
+ {
129
+ path,
130
+ tracked: isTracked,
131
+ found: "per-repository server entry",
132
+ remove: () => {
133
+ const copy = isTracked ? undefined : backup(path, now);
134
+ const current = readJson(path) ?? {};
135
+ const remaining = { ...(current.mcpServers ?? {}) };
136
+ delete remaining.balladeer;
137
+ writeJsonAtomically(path, JSON.stringify({ ...current, mcpServers: remaining }, null, 2) + "\n");
138
+ return copy;
139
+ },
140
+ },
141
+ ];
142
+ }
143
+ function codexBlocks(root, now) {
144
+ const path = join(root, ".codex", "config.toml");
145
+ if (!regularFile(path))
146
+ return [];
147
+ const text = readFileSync(path, "utf8");
148
+ const present = CODEX_BLOCKS.filter(([start, end]) => text.includes(`${start}\n`) && text.includes(end));
149
+ if (present.length === 0)
150
+ return [];
151
+ const isTracked = tracked(root, ".codex/config.toml");
152
+ return [
153
+ {
154
+ path,
155
+ tracked: isTracked,
156
+ found: `per-repository Codex block${present.length === 1 ? "" : "s"}`,
157
+ remove: () => {
158
+ const copy = isTracked ? undefined : backup(path, now);
159
+ let after = readFileSync(path, "utf8");
160
+ for (const [start, end] of CODEX_BLOCKS) {
161
+ const from = after.indexOf(`${start}\n`);
162
+ const to = after.indexOf(end, from);
163
+ if (from < 0 || to < 0)
164
+ continue;
165
+ const cut = to + end.length + (after[to + end.length] === "\n" ? 1 : 0);
166
+ after = after.slice(0, from) + after.slice(cut);
167
+ }
168
+ writeJsonAtomically(path, after.replace(/\n{3,}/g, "\n\n"));
169
+ return copy;
170
+ },
171
+ },
172
+ ];
173
+ }
174
+ /** The older per-repository form in the checkout that holds `cwd`, if any. */
175
+ export function findCheckoutForm(input) {
176
+ const root = repositoryRoot(input.cwd);
177
+ if (root === undefined || !existsSync(root))
178
+ return [];
179
+ const now = input.now ?? (() => new Date());
180
+ return [
181
+ ...claudeHooks(root, now),
182
+ ...claudeServer(root, input.controlPlane, now),
183
+ ...codexBlocks(root, now),
184
+ ];
185
+ }
186
+ export function removeCheckoutForm(items) {
187
+ const removed = [];
188
+ const failed = [];
189
+ for (const item of items) {
190
+ try {
191
+ const copy = item.remove();
192
+ removed.push({
193
+ path: item.path,
194
+ found: item.found,
195
+ tracked: item.tracked,
196
+ ...(copy === undefined ? {} : { copy }),
197
+ });
198
+ }
199
+ catch (error) {
200
+ failed.push({
201
+ path: item.path,
202
+ found: item.found,
203
+ reason: error instanceof Error ? error.message : String(error),
204
+ });
205
+ }
206
+ }
207
+ return { removed, failed };
208
+ }
209
+ export function describeCheckoutFormRemoval(report) {
210
+ const lines = [];
211
+ for (const entry of report.removed)
212
+ lines.push(`Removed the older ${entry.found} from ${entry.path}` +
213
+ (entry.copy
214
+ ? ` (copy at ${entry.copy}).`
215
+ : entry.tracked
216
+ ? "; git tracks this file, so commit the change."
217
+ : "."));
218
+ for (const entry of report.failed)
219
+ lines.push(`Could not remove the older ${entry.found} from ${entry.path}: ${entry.reason}`);
220
+ if (report.removed.length > 0)
221
+ lines.push("The user-scope hook now speaks alone in this checkout; it was saying the same thing twice.");
222
+ return lines;
223
+ }
package/dist/cli.js CHANGED
@@ -21,9 +21,12 @@ import { runWhoami } from "./commands/whoami.js";
21
21
  import { runInstall } from "./install.js";
22
22
  import { openInBrowser } from "./open-browser.js";
23
23
  import { PUBLISHED_SPECIFIER } from "./release.js";
24
- import { installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
24
+ import { CODEX_HOOK_APPROVAL, installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
25
25
  import { updateNotice } from "./currency.js";
26
26
  import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "./remove-earlier.js";
27
+ import { recordSelfUpdateState, SELF_UPDATE_ENVIRONMENT } from "./self-update.js";
28
+ import { hooksAwaitingTrust } from "./hook-trust.js";
29
+ import { describeCheckoutFormRemoval, findCheckoutForm, removeCheckoutForm, } from "./checkout-form.js";
27
30
  import { StoreError, normalizeControlPlane } from "./store.js";
28
31
  import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
29
32
  const USAGE = `balladeer ${CLI_VERSION}
@@ -471,11 +474,46 @@ function installEverything(options) {
471
474
  for (const w of writes ?? [])
472
475
  if (!options.json)
473
476
  options.write(`${w.status === "refused" ? "Not changed" : w.status === "written" ? "Registered" : "Already current"}: ${w.path}${w.reason ? ` (${w.reason})` : ""}\n`);
477
+ // Codex runs a hook only once the person has trusted it, and trusts it by
478
+ // the hash of its definition, so a hook this command just wrote or changed
479
+ // is skipped until they do. Found 29 September 2026: Codex was the most
480
+ // common host at Didero and not one of its sessions had run the hook.
481
+ const codex = (writes ?? []).find((w) => w.host === "codex" && w.status !== "refused");
482
+ // Said whenever it is true, which is after any install that wrote or changed
483
+ // the hooks and until Codex has run one of them; silent once it has.
484
+ if (codex !== undefined && hooksAwaitingTrust(process.env, "codex")) {
485
+ if (options.json)
486
+ options.write(`${JSON.stringify({ step: "codex_hooks", approval: codex.status === "written" ? "required" : "check", command: "/hooks" })}\n`);
487
+ else
488
+ options.write(`${CODEX_HOOK_APPROVAL}\n`);
489
+ }
490
+ // With the user-scope form in place, the older per-repository form in this
491
+ // checkout only makes the session hear the guidance twice. Out it goes, with
492
+ // a copy beside any file git does not already keep.
493
+ if ((writes ?? []).length > 0 && !(writes ?? []).some((w) => w.status === "refused")) {
494
+ const checkoutForm = findCheckoutForm({
495
+ cwd: process.cwd(),
496
+ controlPlane: options.controlPlane,
497
+ });
498
+ if (checkoutForm.length > 0) {
499
+ const report = removeCheckoutForm(checkoutForm);
500
+ if (options.json)
501
+ options.write(`${JSON.stringify({ step: "checkout_form", removed: report.removed, failed: report.failed })}\n`);
502
+ else
503
+ for (const line of describeCheckoutFormRemoval(report))
504
+ options.write(`${line}\n`);
505
+ }
506
+ }
474
507
  if (!options.json && (writes ?? []).some((w) => w.status === "written"))
475
508
  options.write("Balladeer now runs in every Claude Code" +
476
509
  ((writes ?? []).some((w) => w.host === "codex") ? " and Codex" : "") +
477
510
  " session on this machine. In a folder of a connected repository it works as before; anywhere else it stays quiet, and `balladeer status` there says why.\n");
478
- return (writes ?? []).some((w) => w.status === "refused") ? 4 : code;
511
+ const outcome = (writes ?? []).some((w) => w.status === "refused") ? 4 : code;
512
+ // Started by the session hook's background update: leave how it ended where
513
+ // the next hook fire will find it and tell the server.
514
+ if (process.env[SELF_UPDATE_ENVIRONMENT] === "1")
515
+ recordSelfUpdateState(process.env, outcome === 0 ? `installed:${CLI_VERSION}` : `failed:exit-${outcome}`, Date.now());
516
+ return outcome;
479
517
  }
480
518
  async function dispatch(parsed, write) {
481
519
  switch (parsed.command) {
@@ -522,11 +560,21 @@ async function dispatch(parsed, write) {
522
560
  return report.failed.length === 0 ? 0 : 6;
523
561
  }
524
562
  case "install":
525
- return installEverything({ json: parsed.json, write, allowPartial: false });
563
+ return installEverything({
564
+ json: parsed.json,
565
+ write,
566
+ allowPartial: false,
567
+ controlPlane: parsed.controlPlane,
568
+ });
526
569
  case "setup":
527
570
  return runSetup({
528
571
  installCommand: () => {
529
- const result = installEverything({ json: parsed.json, write, allowPartial: true });
572
+ const result = installEverything({
573
+ json: parsed.json,
574
+ write,
575
+ allowPartial: true,
576
+ controlPlane: parsed.controlPlane,
577
+ });
530
578
  return process.platform === "win32" ? 0 : result;
531
579
  },
532
580
  controlPlane: parsed.controlPlane,
@@ -1,13 +1,5 @@
1
1
  import { type GuidanceEvent } from "../guidance.js";
2
- /**
3
- * What an agent, or `status`, is told about a folder Balladeer is not tracking.
4
- *
5
- * Robert, 22 September 2026: the session hook says nothing in such a folder.
6
- * A customer with one repository has every other folder on the laptop
7
- * untracked, and a line in each of those is noise in the wrong place. This
8
- * sentence stays for the two readers who ask: `balladeer status` in the folder,
9
- * and an agent that opens the empty catalog and wonders why.
10
- */
2
+ /** A repository-specific setup step for explicit status and empty-catalog responses. */
11
3
  export declare function untrackedLine(remote: string): string;
12
4
  export type GuidanceOptions = Readonly<{
13
5
  controlPlane: string;
@@ -23,7 +15,7 @@ export type GuidanceOptions = Readonly<{
23
15
  fetchImpl?: typeof fetch;
24
16
  now?: () => number;
25
17
  /** Runs the background update; injected so tests never reach npm. */
26
- startUpdate?: (command: string, args: readonly string[]) => void;
18
+ startUpdate?: (command: string, args: readonly string[], environment: NodeJS.ProcessEnv) => boolean | void;
27
19
  }>;
28
20
  /** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
29
21
  export declare function runGuidance(options: GuidanceOptions): Promise<number>;
@@ -5,15 +5,8 @@ import { selectAgent } from "../agent.js";
5
5
  import { repositoryHints } from "../repository.js";
6
6
  import { newerVersionPublished } from "../currency.js";
7
7
  import { startSelfUpdateIfDue } from "../self-update.js";
8
- /**
9
- * What an agent, or `status`, is told about a folder Balladeer is not tracking.
10
- *
11
- * Robert, 22 September 2026: the session hook says nothing in such a folder.
12
- * A customer with one repository has every other folder on the laptop
13
- * untracked, and a line in each of those is noise in the wrong place. This
14
- * sentence stays for the two readers who ask: `balladeer status` in the folder,
15
- * and an agent that opens the empty catalog and wonders why.
16
- */
8
+ import { noteHookFired } from "../hook-trust.js";
9
+ /** A repository-specific setup step for explicit status and empty-catalog responses. */
17
10
  export function untrackedLine(remote) {
18
11
  const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
19
12
  return (`${here} isn't connected to Balladeer on this laptop, so nothing is tracked from here. ` +
@@ -70,8 +63,7 @@ function identifier(input) {
70
63
  /** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
71
64
  export async function runGuidance(options) {
72
65
  // A project hook names its repository and must match the checkout it sits
73
- // in. A user-scope hook names none: the folder's remote decides, and a folder
74
- // with no connection hears that it is untracked rather than nothing at all.
66
+ // in. A user-scope hook names none: the folder's remotes decide.
75
67
  const projectScoped = options.repositoryId !== undefined;
76
68
  if (projectScoped &&
77
69
  !validateProjectGuidanceScope({
@@ -93,23 +85,29 @@ export async function runGuidance(options) {
93
85
  event = parsedEvent;
94
86
  const record = input;
95
87
  const credentials = readCredentials(options.environment).agents;
88
+ const repositories = projectScoped ? [] : repositoryHints(options.cwd);
96
89
  const agents = projectScoped
97
90
  ? credentials.filter((agent) => agent.controlPlane === options.controlPlane &&
98
91
  agent.repositoryId === options.repositoryId)
99
92
  : (() => {
100
- const selection = selectAgent(credentials, options.controlPlane, undefined, repositoryHints(options.cwd));
93
+ const selection = selectAgent(credentials, options.controlPlane, undefined, repositories);
101
94
  return selection.kind === "refused" ? [] : [selection.agent];
102
95
  })();
103
96
  if (agents.length !== 1 || !agents[0]) {
104
97
  if (projectScoped)
105
98
  throw new Error("guidance_connection_unavailable");
106
- // Untracked: nothing is said. `status` in the folder says why.
99
+ // Unconnected folders stay silent. An explicit status request explains setup.
107
100
  return 0;
108
101
  }
102
+ // A hook that runs has been trusted by its host; hosts that ask for that
103
+ // trust are the ones this matters to.
104
+ if (options.hook === "codex")
105
+ noteHookFired(options.environment, "codex");
109
106
  const loaded = await loadGuidance({
110
107
  agent: agents[0],
111
108
  environment: options.environment,
112
109
  timeoutMs: 750,
110
+ hook: options.hook,
113
111
  ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
114
112
  });
115
113
  // Behind? Start the install in the background, once a day at most, and
@@ -63,3 +63,9 @@ export declare function staleClientFrame(id: unknown, update: string): string;
63
63
  */
64
64
  export declare function revokedConnectionFrame(id: unknown, controlPlane: string): string;
65
65
  export declare function runMcp(options: McpOptions): Promise<number>;
66
+ /**
67
+ * The tool's answer with one more text block at its end, or nothing when the
68
+ * answer is not a single tool result this can add to: a batch, an error frame,
69
+ * or anything it cannot parse is forwarded exactly as it came.
70
+ */
71
+ export declare function withNotice(text: string, notice: string): string | undefined;
@@ -5,6 +5,7 @@ import { noteServerVersion, updateNotice } from "../currency.js";
5
5
  import { repositoryHint, repositoryHints } from "../repository.js";
6
6
  import { CLI_VERSION } from "../wire.js";
7
7
  import { untrackedLine } from "./guidance.js";
8
+ import { HOOKS_AWAITING_TRUST_NOTICE, hooksAwaitingTrust, trustingHost } from "../hook-trust.js";
8
9
  import { StoreError, readCredentials } from "../store.js";
9
10
  // The three commands that use an agent connection select and address it through
10
11
  // one module. These are re-exported because this is where the forwarder's
@@ -198,6 +199,11 @@ export async function runMcp(options) {
198
199
  if (guidanceInstall.status !== "not_applicable")
199
200
  options.error(`Balladeer guidance loader: ${guidanceInstall.status}${guidanceInstall.reason ? ` (${guidanceInstall.reason})` : ""}. New project hooks may need host trust; no trust is assumed.\n`);
200
201
  let saidUpdate = false;
202
+ // Which host this session belongs to is known from its hello; whether its
203
+ // hooks are waiting for the person's trust is asked at the first tool call,
204
+ // by which time a trusted session-start hook has certainly run.
205
+ let host;
206
+ let saidTrust = false;
201
207
  const lines = createInterface({ input: options.stdin, crlfDelay: Infinity });
202
208
  for await (const line of lines) {
203
209
  if (line.trim().length === 0)
@@ -247,7 +253,54 @@ export async function runMcp(options) {
247
253
  options.error("Balladeer refused a response frame larger than 4 MiB.\n");
248
254
  return 5;
249
255
  }
256
+ const request = frameOf(line);
257
+ if (request?.method === "initialize")
258
+ host = trustingHost(request.params?.clientInfo?.name);
259
+ if (!saidTrust &&
260
+ host !== undefined &&
261
+ request?.method === "tools/call" &&
262
+ hooksAwaitingTrust(options.environment, host)) {
263
+ const noted = withNotice(text, HOOKS_AWAITING_TRUST_NOTICE);
264
+ if (noted !== undefined) {
265
+ saidTrust = true;
266
+ options.write(`${noted}\n`);
267
+ continue;
268
+ }
269
+ }
250
270
  options.write(`${text.replace(/\n+$/, "")}\n`);
251
271
  }
252
272
  return 0;
253
273
  }
274
+ function frameOf(line) {
275
+ try {
276
+ const parsed = JSON.parse(line);
277
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
278
+ ? parsed
279
+ : undefined;
280
+ }
281
+ catch {
282
+ return undefined;
283
+ }
284
+ }
285
+ /**
286
+ * The tool's answer with one more text block at its end, or nothing when the
287
+ * answer is not a single tool result this can add to: a batch, an error frame,
288
+ * or anything it cannot parse is forwarded exactly as it came.
289
+ */
290
+ export function withNotice(text, notice) {
291
+ try {
292
+ const frame = JSON.parse(text);
293
+ if (frame === null || typeof frame !== "object" || Array.isArray(frame))
294
+ return undefined;
295
+ const content = frame.result?.content;
296
+ if (!Array.isArray(content))
297
+ return undefined;
298
+ return JSON.stringify({
299
+ ...frame,
300
+ result: { ...frame.result, content: [...content, { type: "text", text: notice }] },
301
+ });
302
+ }
303
+ catch {
304
+ return undefined;
305
+ }
306
+ }