hermoso 0.1.533 → 0.1.535

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
@@ -22,29 +22,54 @@ campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a
22
22
  - **Posting.** Post and schedule to your own social channels, then read each post's results. The channel list
23
23
  is under *What it connects to* below.
24
24
 
25
- ## Set up in Claude, ChatGPT or Cursor
25
+ ## Set up in ChatGPT, Claude, Grok Bot or a coding agent
26
+
27
+ **ChatGPT:** one click. **[Add to ChatGPT](https://chatgpt.com/plugins/plugin_asdk_app_6abde87d08048191b2ee7cda76721749)** opens Hermoso in ChatGPT's app
28
+ directory. Press Install plugin and sign in with your Hermoso account, no key needed. Then
29
+ **[open a new ChatGPT chat with your first message typed](https://chatgpt.com/?q=Hermoso%2C%20set%20up%20my%20brand%20from%20my%20website%20and%20make%20my%20first%20ad)**, or paste it yourself:
30
+ `Hermoso, set up my brand from my website and make my first ad`
26
31
 
27
32
  **Claude (claude.ai, Claude Desktop):** one click. **[Add to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Hermoso&connectorUrl=https%3A%2F%2Fapp.hermoso.ai%2Fmcp%3Fsrc%3Dreadme)**
28
33
  opens Claude's *Add custom connector* dialog with Hermoso already filled in. Press Continue, choose **Sign in now**
29
34
  if Claude asks about Authentication, then sign in with your Hermoso account.
30
35
 
31
- By hand: Customize → Connectors → Add → *Add custom connector*, paste the URL below, choose **Sign in now** under
36
+ **Grok Bot:** it cannot open the Hermoso sign-in page, so it signs in with a key. **[Create a key](https://app.hermoso.ai/?tab=api-keys)**,
37
+ then **[add the Hermoso Marketing Bot](https://x.ai/bot/THQqogUKuhn1whWU0hrmb)** and paste the key when it asks.
38
+
39
+ **Claude Code, Cursor, Codex and other coding agents:** one prompt. Paste this into a new chat and the agent installs
40
+ Hermoso, opens the sign-in and makes your first ad (this is Claude Code's install line; each agent's own is in the app
41
+ under MCP & CLI, and in [Install in one command](#install-in-one-command)):
42
+
43
+ ```text
44
+ Set up Hermoso for me. It is an AI marketing tool (hermoso.ai) that researches ads, makes image and video ads and posts to my social accounts.
45
+ 1. Install it by running: claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermoso@hermoso
46
+ If the new skills only load after a restart, tell me, and use npx meanwhile.
47
+ 2. Sign me in by running: npx -y hermoso auth login
48
+ It opens my browser and I will approve the connection there. If this machine has no browser, ask me for a Hermoso API key and run npx -y hermoso auth login --token <key> instead.
49
+ 3. Once I am signed in, do one small task to show it works: tell me my Hermoso credit balance and which profile is saved.
50
+ 4. Then set up my brand from my website and make my first ad.
51
+ ```
52
+
53
+ <details>
54
+ <summary>Set it up by hand</summary>
55
+
56
+ **Claude by hand:** Customize → Connectors → Add → *Add custom connector*, paste the URL below, choose **Sign in now** under
32
57
  Authentication, then Add and Connect. On a Team or Enterprise plan an Owner adds it for the organization first.
33
58
 
34
59
  ```text
35
60
  https://app.hermoso.ai/mcp
36
61
  ```
37
62
 
63
+ **ChatGPT by hand:** turn on Developer mode once (Settings → Security and login), then Plugins → + → Create app →
64
+ Create MCP App. Paste `https://app.hermoso.ai/mcp` as the Server URL, choose OAuth, leave the advanced OAuth
65
+ settings empty, and press Create.
66
+
38
67
  **Claude Code:**
39
68
 
40
69
  ```bash
41
70
  claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermoso@hermoso
42
71
  ```
43
72
 
44
- **ChatGPT:** turn on Developer mode once (Settings → Security and login), then Plugins → + → Create app →
45
- Create MCP App. Paste `https://app.hermoso.ai/mcp` as the Server URL, choose OAuth, leave the advanced OAuth
46
- settings empty, and press Create.
47
-
48
73
  **Cursor:**
49
74
 
50
75
  ```bash
@@ -57,6 +82,8 @@ Or add the MCP server to Cursor's `mcp.json` after `npx -y hermoso auth login`:
57
82
  { "mcpServers": { "hermoso": { "command": "npx", "args": ["-y", "hermoso", "mcp"] } } }
58
83
  ```
59
84
 
85
+ </details>
86
+
60
87
  Codex, Gemini CLI, Grok and the rest are in [Install in one command](#install-in-one-command).
61
88
 
62
89
  ## Examples
@@ -71,7 +98,7 @@ Ask in your own words. The agent picks the tools:
71
98
  - "Compare last week's Google Ads and Meta Ads results and tell me what to pause."
72
99
  - "Schedule this video to TikTok, Instagram Reels and YouTube Shorts for Tuesday at 9am."
73
100
 
74
- **1231 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
101
+ **1233 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
75
102
  catalog with exact per-render credit costs plus the full capability map.
76
103
 
77
104
  **No markup on models** on Starter, Pro and Studio. Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
@@ -311,7 +338,7 @@ block entirely if you signed in above; it is there for CI, where the process can
311
338
 
312
339
  Then ask your agent: *“Generate an image ad with Hermoso.”*
313
340
 
314
- ### What the 1231 tools cover
341
+ ### What the 1233 tools cover
315
342
 
316
343
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
317
344
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
@@ -0,0 +1,120 @@
1
+ // ── THE BRAND'S FONTS AND COLOURS, EDITABLE EVERYWHERE (2026-11-13) ─────────────────────────────────────────────
2
+ //
3
+ // A paying customer wrote in: his profile still held the fonts and palette the site scrape guessed at onboarding,
4
+ // and nothing let him change them. The Profile showed only the free-text "Visual style", and update_brand had no
5
+ // fonts or palette field. These are the two parsers every door now goes through: the MCP twins (so the CLI and
6
+ // /v1/tools), the Studio agent's update_brand, the web Profile (its own inline copy, pinned to these fixtures by
7
+ // tools/brand-look-check.mjs) and the admin brand-field route.
8
+ //
9
+ // It lives in mcp/ rather than lib/ for the reason roster-scope.mjs does: the npm package ships `cli/mcp/**` and
10
+ // nothing else, so `./brand-look.mjs` is the one path that resolves from BOTH tools.mjs twins. cli/mcp/brand-look.mjs
11
+ // is its byte twin (tools/mcp-parity.mjs sweeps every shared file).
12
+ //
13
+ // THE STORED SHAPES DO NOT CHANGE. `fonts` stays what the onboarding draft writes: an ORDERED array of family names,
14
+ // headline face first, body face second (templates/font-map.mjs reads it positionally). `palette` stays an array of
15
+ // hex strings. Both doors are accepted for fonts: the array as stored, or {headline, body}, which is merged onto the
16
+ // fonts already saved so changing one role leaves the other alone.
17
+ //
18
+ // NOTHING THE USER WROTE IS SILENTLY CHANGED. A value is trimmed and otherwise kept exactly as typed (case included);
19
+ // anything we cannot use is REFUSED with a sentence that names it, and nothing is saved. A short hex (#abc) or one
20
+ // without its # is refused rather than expanded, because every renderer reads the six-digit form only.
21
+
22
+ export const PALETTE_MAX = 8;
23
+ export const FONTS_MAX = 4;
24
+ export const FONT_NAME_MAX = 60;
25
+ export const HEX_RE = /^#[0-9a-fA-F]{6}$/;
26
+
27
+ const s = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
28
+ const show = (v) => JSON.stringify(s(v).slice(0, 40));
29
+
30
+ /** One colour → '' when usable, else the sentence saying why not. Pure. */
31
+ export function hexRefusal(v) {
32
+ if (typeof v !== 'string') return `Each colour must be written as text like "#3D4C41".`;
33
+ const t = v.trim();
34
+ if (!t) return 'A colour is empty. Remove it or write it like "#3D4C41".';
35
+ if (HEX_RE.test(t)) return '';
36
+ if (/^[0-9a-fA-F]{6}$/.test(t)) return `${show(t)} needs a # in front: "#${t}".`;
37
+ if (/^#?[0-9a-fA-F]{3}$/.test(t)) return `${show(t)} is a short colour code. Write all six digits, like "#AABBCC".`;
38
+ return `${show(t)} is not a colour code. Write it as # and six digits or letters A–F, like "#3D4C41".`;
39
+ }
40
+
41
+ /**
42
+ * The palette a caller asked for → { value } (the array to store) or { error }. An empty array clears it. Pure.
43
+ */
44
+ export function parseBrandPalette(input) {
45
+ if (input == null) return { error: 'palette must be a list of colour codes, like ["#3D4C41", "#EEE4DA"].' };
46
+ const list = typeof input === 'string' ? input.split(/[\s,]+/).filter(Boolean) : input;
47
+ if (!Array.isArray(list)) return { error: 'palette must be a list of colour codes, like ["#3D4C41", "#EEE4DA"].' };
48
+ if (list.length > PALETTE_MAX) return { error: `A palette holds up to ${PALETTE_MAX} colours; this one has ${list.length}.` };
49
+ const out = [], seen = new Set();
50
+ for (const c of list) {
51
+ const why = hexRefusal(c);
52
+ if (why) return { error: why };
53
+ const t = c.trim();
54
+ if (seen.has(t.toLowerCase())) return { error: `${t} is in the palette twice.` };
55
+ seen.add(t.toLowerCase()); out.push(t);
56
+ }
57
+ return { value: out };
58
+ }
59
+
60
+ /** One font name → '' when usable, else the sentence saying why not. Any family name is accepted. Pure. */
61
+ export function fontNameRefusal(v) {
62
+ if (typeof v !== 'string') return 'Each font must be a family name written as text, like "Josefin Sans".';
63
+ const t = v.trim();
64
+ if (!t) return 'A font name is empty.';
65
+ if (t.length > FONT_NAME_MAX) return `${show(t)}… is longer than ${FONT_NAME_MAX} characters; write just the family name.`;
66
+ // Characters that would end a CSS family name or a prompt line. Letters in any alphabet, digits, spaces, hyphens,
67
+ // dots, ampersands and apostrophe-free punctuation stay; nothing is stripped, the name is refused instead.
68
+ if (/[\u0000-\u001f"'`<>{};\\]/.test(t)) return `${show(t)} contains a character a font name cannot have (quotes, < > { } ; or \\).`;
69
+ return '';
70
+ }
71
+
72
+ /**
73
+ * The fonts a caller asked for → { value } (the ordered array to store) or { error }.
74
+ * `input` is an array (headline first, body second, as stored) or { headline, body } merged onto `existing`;
75
+ * an empty string in the object form removes that role. An empty array clears the fonts. Pure.
76
+ */
77
+ export function parseBrandFonts(input, existing = []) {
78
+ const cur = (Array.isArray(existing) ? existing : []).filter((x) => typeof x === 'string' && x.trim());
79
+ if (Array.isArray(input)) {
80
+ if (input.length > FONTS_MAX) return { error: `Up to ${FONTS_MAX} fonts; the first is used for headlines and the second for body text.` };
81
+ const out = [];
82
+ for (const f of input) { const why = fontNameRefusal(f); if (why) return { error: why }; const t = f.trim(); if (out.some((o) => o.toLowerCase() === t.toLowerCase())) return { error: `${t} is listed twice.` }; out.push(t); }
83
+ return { value: out };
84
+ }
85
+ if (input && typeof input === 'object') {
86
+ const keys = Object.keys(input).filter((k) => input[k] !== undefined);
87
+ const bad = keys.filter((k) => k !== 'headline' && k !== 'body');
88
+ if (bad.length) return { error: `fonts takes headline and body (or a list); ${bad.map((k) => JSON.stringify(k)).join(', ')} is not one of them.` };
89
+ if (!keys.length) return { error: 'fonts needs a headline or a body font (or a list).' };
90
+ let head = cur[0] || '', body = cur[1] || '';
91
+ const rest = cur.slice(2);
92
+ for (const role of ['headline', 'body']) {
93
+ if (input[role] === undefined) continue;
94
+ const v = input[role] == null ? '' : input[role];
95
+ if (typeof v === 'string' && !v.trim()) { if (role === 'headline') head = ''; else body = ''; continue; }
96
+ const why = fontNameRefusal(v); if (why) return { error: why };
97
+ if (role === 'headline') head = v.trim(); else body = v.trim();
98
+ }
99
+ // A body font with no headline font keeps its place as the one face; the order is what renderers read.
100
+ const out = [head, body, ...rest].filter(Boolean);
101
+ const dedup = []; for (const f of out) if (!dedup.some((d) => d.toLowerCase() === f.toLowerCase())) dedup.push(f);
102
+ return { value: dedup };
103
+ }
104
+ if (typeof input === 'string') return { error: 'fonts takes a list (headline first, then body) or {headline, body}, not a single string.' };
105
+ return { error: 'fonts takes a list (headline first, then body) or {headline, body}.' };
106
+ }
107
+
108
+ /** The roles an ordered font list carries: the first is the headline face, the second the body face. Pure. */
109
+ export function brandFontRoles(fonts) {
110
+ const f = (Array.isArray(fonts) ? fonts : []).filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim());
111
+ return { headline: f[0] || '', body: f[1] || f[0] || '', extra: f.slice(2) };
112
+ }
113
+
114
+ /** "Benne (headlines), Josefin Sans (body)" — how a reply, get_brand and the planner name the fonts. Pure. */
115
+ export function describeBrandFonts(fonts) {
116
+ const f = (Array.isArray(fonts) ? fonts : []).filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim());
117
+ if (!f.length) return '';
118
+ if (f.length === 1) return `${f[0]} (headlines and body)`;
119
+ return [`${f[0]} (headlines)`, `${f[1]} (body)`, ...f.slice(2)].join(', ');
120
+ }
package/mcp/client.mjs CHANGED
@@ -179,7 +179,7 @@ async function unwrap(res, { read = false } = {}) {
179
179
  // ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
180
180
  // throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
181
181
  // `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
182
- throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(res.status === 429 ? { retryAfter: Number(body?.retryAfter) || Number(res.headers?.get?.('retry-after')) || 60 } : {}), ...(typeof body?.code === 'string' ? { apiCode: body.code } : {}), ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}), ...(body?.buy && typeof body.buy === 'object' && body.buy.pack ? { buy: body.buy } : {}) }); // `buy` (2026-11-12): the two priced doors a balance refusal carries (server buyDoorsFor): a pack and a plan, each with its price and a checkout link // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
182
+ throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(res.status === 429 ? { retryAfter: Number(body?.retryAfter) || Number(res.headers?.get?.('retry-after')) || 60 } : {}), ...(typeof body?.code === 'string' ? { apiCode: body.code } : {}), ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.rewardsSettled && typeof body.meta.rewardsSettled === 'object' ? { rewardsSettled: { credits: Number(body.meta.rewardsSettled.credits) || 0 } } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}), ...(body?.buy && typeof body.buy === 'object' && body.buy.pack ? { buy: body.buy } : {}) }); // `buy` (2026-11-12): the two priced doors a balance refusal carries (server buyDoorsFor): a pack and a plan, each with its price and a checkout link // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
183
183
  }
184
184
  // WHAT THIS CALL WAS CHARGED (2026-10-19). A paid route answers {data, creditsUsed}, and the unwrap below drops the
185
185
  // sibling, so a synchronous paid tool's reply could never say what it cost. It is added to the running tool call's
package/mcp/http.mjs CHANGED
@@ -177,7 +177,36 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
177
177
  const rpcCancelledIdKeys = (body) => (Array.isArray(body) ? body : [body]).filter((m) => m && m.method === 'notifications/cancelled' && m.params && m.params.requestId !== undefined && m.params.requestId !== null).map((m) => rpcIdKey(m.params.requestId));
178
178
  const GET_STREAM_MAX = Math.max(1, Number(process.env.MCP_GET_STREAM_MAX || 64));
179
179
  let openGetStreams = 0;
180
- const streamStats = () => ({ open: openGetStreams, max: GET_STREAM_MAX, lifeMs: GET_STREAM_MS });
180
+ // ── A STOPPING OR RETIRED INSTANCE HANDS ITS STREAMS BACK, IT DOES NOT LET THEM BREAK (2026-11-13) ──────────────────
181
+ // On SIGTERM, and when this revision has lost the traffic (server.js, lib/pump-handover.mjs retiredVerdict), every open
182
+ // GET stream is ended CLEANLY with an SSE `retry:` field first, the spec's own reconnection hint, so the client reopens
183
+ // it at once and the reopen lands on the serving revision (a stream cut by process exit reads as a network error, and
184
+ // some clients back off). While draining, a new GET stream is answered 503 + Retry-After (it is optional, the spec lets a
185
+ // server refuse it) and POSTs are untouched: a tool call in flight is never cut by this, and a session id this instance
186
+ // holds keeps working until its own idle expiry. A session the client reopens elsewhere gets the 404 → re-initialize
187
+ // path below, which is how every deploy already ends a session. undrain() reverses it (a rollback brings the traffic back).
188
+ const openGetResponses = new Set();
189
+ let draining = '';
190
+ const RECONNECT_HINT_MS = 1000;
191
+ function drain(why = 'draining') {
192
+ if (!draining) console.error(`[mcp-remote] draining (${why}): ending ${openGetResponses.size} notification stream(s) with a reconnect hint`);
193
+ draining = String(why || 'draining');
194
+ let ended = 0;
195
+ for (const res of [...openGetResponses]) {
196
+ try {
197
+ if (!res.writableEnded && !res.destroyed) {
198
+ // An open 200 on a GET here IS the SDK's SSE stream (its headers go through writeHead, so getHeader cannot see them;
199
+ // any refusal it answers is JSON and already ended). Events are written whole, so a field between them is valid SSE.
200
+ if (res.headersSent && res.statusCode === 200) res.write(`retry: ${RECONNECT_HINT_MS}\n\n`);
201
+ res.end(); ended++;
202
+ }
203
+ } catch {}
204
+ openGetResponses.delete(res);
205
+ }
206
+ return { ended, sessions: sessions.size };
207
+ }
208
+ function undrain() { if (draining) console.error(`[mcp-remote] no longer draining (was: ${draining})`); draining = ''; }
209
+ const streamStats = () => ({ open: openGetStreams, max: GET_STREAM_MAX, lifeMs: GET_STREAM_MS, draining: draining || null });
181
210
  // THE 401 IS THE ONLY THING A STUCK AGENT EVER READS, so it carries the way out (2026-09-04). It used to be the
182
211
  // three words `Authentication required`, and that is exactly how far a real user got: a Cursor-based client
183
212
  // (Grok Bot) registered fine, listed all 160 tools, and then every call 401'd while its own consent card never
@@ -488,13 +517,18 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
488
517
 
489
518
  // The stream cap, BEFORE any session work: a burst of reconnects must be refused at the door, not after allocating.
490
519
  if (req.method === 'GET') {
520
+ if (draining) {
521
+ res.set('Retry-After', '1');
522
+ return res.status(503).json({ error: 'This instance is handing over to a new one: reopen the stream (it reconnects to the new instance). Tool calls (POST) are unaffected.' });
523
+ }
491
524
  if (openGetStreams >= GET_STREAM_MAX) {
492
525
  res.set('Retry-After', '30');
493
526
  return res.status(503).json({ error: `This instance already holds ${openGetStreams} open notification streams — retry the stream in 30s. Tool calls (POST) are unaffected.` });
494
527
  }
495
528
  openGetStreams++;
496
529
  const t = setTimeout(() => { try { res.end(); } catch {} }, GET_STREAM_MS); if (t && typeof t.unref === 'function') t.unref();
497
- res.once('close', () => { openGetStreams = Math.max(0, openGetStreams - 1); clearTimeout(t); });
530
+ openGetResponses.add(res);
531
+ res.once('close', () => { openGetStreams = Math.max(0, openGetStreams - 1); clearTimeout(t); openGetResponses.delete(res); });
498
532
  }
499
533
 
500
534
  const sid = req.headers['mcp-session-id'];
@@ -686,6 +720,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
686
720
 
687
721
  console.error(`[mcp-remote] mounted at ${BASE || '(set HERMOSO_PUBLIC_URL)'}/mcp`);
688
722
  app.locals.mcpStreamStats = streamStats; // for the check and the admin read; the return value stays `true` as every caller asserts
723
+ app.locals.mcpDrain = drain; app.locals.mcpUndrain = undrain; // server.js: on SIGTERM and while this revision is retired
689
724
  return true;
690
725
  }
691
726
 
@@ -1278,7 +1278,7 @@ export const neutralNote = (t) => {
1278
1278
  // sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
1279
1279
  // tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1, and the check
1280
1280
  // fails if that list gains a type this one lacks. A job queued for LATER is not in progress either.
1281
- export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts']);
1281
+ export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts', 'winback']);
1282
1282
  const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
1283
1283
  const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
1284
1284
  const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
@@ -230,7 +230,7 @@ export const metaAlternativeNote = (name) => INSTAGRAM_LOGIN_TOOLS.has(String(na
230
230
  // The Shopify tools (list_shopify_products, publish_to_shopify_product, the marketing-activity tools) work only for an
231
231
  // account created by installing Hermoso from the Shopify App Store: the store is derived server-side from the verified
232
232
  // account and there is no "connect Shopify" tile. So they cannot ride TOOL_PROVIDER_RULES — every provider there must
233
- // be a CONNECTOR_INFO key (property 3), and "connect it under Settings ▸ Connectors" would be a dead end. Instead the
233
+ // be a CONNECTOR_INFO key (property 3), and "connect it under Connectors" would be a dead end. Instead the
234
234
  // same /api/connectors/providers read answers `shopifyStore`: true (this account has a live store), false (it does not,
235
235
  // or Shopify is off on this deployment), or absent when the server could not tell. ONLY a definite false holds them
236
236
  // back — an unreadable answer keeps them, like every other read of this set (property 1). Held, they are answered by
@@ -1,6 +1,6 @@
1
1
  // ── HINTS: THE NEXT STEP, NAMED (2026-09-17) ───────────────────────────────────────────────────────────────────
2
2
  //
3
- // This product already tells an agent what to do next, constantly and well — "connect it under Settings ▸
3
+ // This product already tells an agent what to do next, constantly and well — "connect it under
4
4
  // Connectors, then call again", "run buy_credits to top up", "call find_tools then call_tool", "reconnect with
5
5
  // ?tools=all". Every one of those is a SENTENCE inside a wall of other sentences, so reading it is a parsing job
6
6
  // an agent may or may not do. Monid rides a `Hints` block on its responses — server-suggested next command,