brand-manager-worker 0.1.0 → 0.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brand-manager-worker",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "The Goose Tools brand-deal worker — your computer reads your brand email and drafts replies in your voice for goosetools.com, using your own Claude account. Drafts only; it never sends.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,275 @@
1
+ // Agent CLI adapter — runs a job's prompt through whichever coding-agent CLI
2
+ // the user picked on goosetools.com (claim payloads carry `agentCli`).
3
+ //
4
+ // VENDORED FILE: keep byte-identical across brand-manager-worker,
5
+ // caption-maker, and carousel-maker (worker/agent-cli.js in each). Fix a bug
6
+ // here, copy it to the other two.
7
+ //
8
+ // Modes, chosen for speed-vs-capability (see brand-manager commit c0c1b33 —
9
+ // tool-looping turned 36-second drafts into 10-minute ones):
10
+ // "text" — plain prompt-in/text-out, no tool flags at all (captions).
11
+ // "oneshot" — tools explicitly OFF, all context inlined by the caller.
12
+ // "read" — file-reading allowed (carousel drafts look at photos).
13
+ // "session" — tools ON and resumable where the CLI supports it.
14
+ //
15
+ // Honest capability notes per CLI:
16
+ // claude — full support: fine tool control, JSON envelope, --resume.
17
+ // codex — sandbox-level tool control only; resumable threads; JSONL out.
18
+ // gemini — no headless resume: sessionId is always null, callers replay
19
+ // history (they already do this whenever sessionId is absent).
20
+ // opencode — plain text out; session continuation is version-dependent, so
21
+ // we don't claim it: sessionId is always null.
22
+ //
23
+ // Session ids are namespaced ("claude:<uuid>", "codex:<thread-id>") because
24
+ // the server stores them per conversation and the user can switch agents
25
+ // between turns — a Claude uuid means nothing to Codex. resolveSession()
26
+ // drops a mismatched id so the caller falls back to history replay. Bare
27
+ // un-prefixed ids predate namespacing and are treated as Claude's.
28
+
29
+ import { spawn } from "node:child_process";
30
+ import { spawnSync } from "node:child_process";
31
+
32
+ export const AGENT_IDS = ["claude-code", "codex", "gemini", "opencode"];
33
+
34
+ const INSTALL_HINTS = {
35
+ "claude-code": "npm install -g @anthropic-ai/claude-code",
36
+ codex: "npm install -g @openai/codex, then run: codex login",
37
+ gemini: "npm install -g @google/gemini-cli, then run: gemini (to sign in)",
38
+ opencode: "npm install -g opencode-ai, then run: opencode auth login",
39
+ };
40
+
41
+ const LABELS = {
42
+ "claude-code": "Claude Code",
43
+ codex: "Codex CLI",
44
+ gemini: "Gemini CLI",
45
+ opencode: "OpenCode",
46
+ };
47
+
48
+ // Session-id namespace per agent. claude-code writes "claude:" for continuity
49
+ // with the bare ids already stored server-side.
50
+ const SESSION_PREFIX = {
51
+ "claude-code": "claude",
52
+ codex: "codex",
53
+ gemini: "gemini",
54
+ opencode: "opencode",
55
+ };
56
+
57
+ function bin(agent) {
58
+ if (agent === "claude-code") return process.env.CLAUDE_BIN ?? "claude";
59
+ return agent; // codex / gemini / opencode binaries share their id
60
+ }
61
+
62
+ /** "claude" (legacy claim payloads) → "claude-code"; unknown → null. */
63
+ export function normalizeAgent(v) {
64
+ if (v == null || v === "" ) return "claude-code";
65
+ if (v === "claude") return "claude-code";
66
+ return AGENT_IDS.includes(v) ? v : null;
67
+ }
68
+
69
+ /**
70
+ * Turn a stored (possibly namespaced, possibly another agent's) session id
71
+ * into one usable by `agent`, or null — null tells the caller to inline the
72
+ * conversation history instead of resuming.
73
+ */
74
+ export function resolveSession(agent, sessionId) {
75
+ if (!sessionId) return null;
76
+ const idx = sessionId.indexOf(":");
77
+ if (idx === -1) {
78
+ // Legacy bare id — those were always Claude Code session uuids.
79
+ return agent === "claude-code" ? sessionId : null;
80
+ }
81
+ const prefix = sessionId.slice(0, idx);
82
+ return prefix === SESSION_PREFIX[agent] ? sessionId.slice(idx + 1) : null;
83
+ }
84
+
85
+ function namespaced(agent, rawId) {
86
+ return rawId ? `${SESSION_PREFIX[agent]}:${rawId}` : null;
87
+ }
88
+
89
+ // Strip ANSI escapes and other terminal noise some CLIs print around answers.
90
+ export function cleanText(text) {
91
+ // eslint-disable-next-line no-control-regex
92
+ return String(text).replace(/\[[0-9;]*[A-Za-z]/g, "").trim();
93
+ }
94
+
95
+ // stdio[0] MUST be "ignore". Inherited stdin makes invocations hang forever
96
+ // under launchd — brand-manager hit this exact bug (fixed in 3b1f955) and it
97
+ // presents as the worker silently doing nothing after a reboot.
98
+ function run(agent, args, { timeoutMs, cwd }) {
99
+ return new Promise((resolve, reject) => {
100
+ const child = spawn(bin(agent), args, {
101
+ cwd,
102
+ stdio: ["ignore", "pipe", "pipe"],
103
+ });
104
+ let stdout = "";
105
+ let stderr = "";
106
+ child.stdout.on("data", (d) => (stdout += d));
107
+ child.stderr.on("data", (d) => (stderr += d));
108
+ const timer = setTimeout(() => {
109
+ child.kill("SIGKILL");
110
+ reject(new Error(`${LABELS[agent]} timed out after ${timeoutMs}ms`));
111
+ }, timeoutMs);
112
+ child.on("error", (err) => {
113
+ clearTimeout(timer);
114
+ reject(
115
+ err.code === "ENOENT"
116
+ ? new Error(
117
+ `${LABELS[agent]} isn't installed on this computer. Install: ${INSTALL_HINTS[agent]} — or switch your agent back to Claude Code in Setup on goosetools.com.`,
118
+ )
119
+ : err,
120
+ );
121
+ });
122
+ child.on("close", (code) => {
123
+ clearTimeout(timer);
124
+ if (code === 0) resolve(stdout);
125
+ else
126
+ reject(
127
+ new Error(`${LABELS[agent]} exited ${code}: ${stderr.slice(0, 600)}`),
128
+ );
129
+ });
130
+ });
131
+ }
132
+
133
+ /** Is the CLI on PATH? { installed, hint } — cheap, for startup diagnostics. */
134
+ export function detect(agent) {
135
+ const res = spawnSync(bin(agent), ["--version"], {
136
+ stdio: ["ignore", "pipe", "pipe"],
137
+ timeout: 10_000,
138
+ });
139
+ return {
140
+ installed: res.status === 0,
141
+ hint: INSTALL_HINTS[agent],
142
+ label: LABELS[agent],
143
+ };
144
+ }
145
+
146
+ // Tools Claude must NOT touch in oneshot mode — everything is inlined.
147
+ const CLAUDE_TOOLS_OFF = "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch";
148
+
149
+ /**
150
+ * Run a prompt through the chosen agent CLI.
151
+ * Returns { text, sessionId } — sessionId is namespaced and null whenever
152
+ * the CLI can't resume (callers then replay history on the next turn).
153
+ */
154
+ export async function runAgent({
155
+ agent = "claude-code",
156
+ prompt,
157
+ cwd,
158
+ mode = "text",
159
+ sessionId = null,
160
+ tools = "Read,Edit,Write,Glob,Grep",
161
+ timeoutMs = 5 * 60 * 1000,
162
+ }) {
163
+ const normalized = normalizeAgent(agent);
164
+ if (!normalized) {
165
+ throw new Error(
166
+ `Unknown agent "${agent}" — update this worker: npx --yes <package>@latest install`,
167
+ );
168
+ }
169
+ const resume = resolveSession(normalized, sessionId);
170
+
171
+ if (normalized === "claude-code") {
172
+ if (mode === "session") {
173
+ const args = [
174
+ "-p",
175
+ prompt,
176
+ "--output-format",
177
+ "json",
178
+ "--permission-mode",
179
+ "acceptEdits",
180
+ "--allowedTools",
181
+ tools,
182
+ ];
183
+ if (resume) args.push("--resume", resume);
184
+ const raw = await run(normalized, args, { timeoutMs, cwd });
185
+ // The json envelope carries the session id. Be forgiving: a shape
186
+ // change shouldn't lose the user's answer.
187
+ try {
188
+ const parsed = JSON.parse(raw);
189
+ return {
190
+ text: parsed.result ?? parsed.text ?? raw,
191
+ sessionId: namespaced(
192
+ normalized,
193
+ parsed.session_id ?? parsed.sessionId ?? resume ?? null,
194
+ ),
195
+ };
196
+ } catch {
197
+ return { text: raw, sessionId: namespaced(normalized, resume) };
198
+ }
199
+ }
200
+ const args = ["-p", prompt, "--output-format", "text"];
201
+ if (mode === "oneshot") args.push("--disallowedTools", CLAUDE_TOOLS_OFF);
202
+ if (mode === "read") args.push("--allowedTools", "Read");
203
+ const text = await run(normalized, args, { timeoutMs, cwd });
204
+ return { text, sessionId: null };
205
+ }
206
+
207
+ if (normalized === "codex") {
208
+ // Sandbox is the only tool control codex offers; read-only + the prompt's
209
+ // own "don't use tools" language is the closest match to oneshot.
210
+ const sandbox = mode === "session" ? "workspace-write" : "read-only";
211
+ const args = ["exec"];
212
+ if (resume) args.push("resume", resume);
213
+ args.push("--skip-git-repo-check", "--sandbox", sandbox, "--json");
214
+ if (cwd) args.push("--cd", cwd);
215
+ args.push(prompt);
216
+ const raw = await run(normalized, args, { timeoutMs, cwd });
217
+ // --json emits JSONL events; harvest the thread id and the last agent
218
+ // message, falling back to the raw output if the shape ever changes.
219
+ let threadId = resume ?? null;
220
+ let last = null;
221
+ for (const line of raw.split("\n")) {
222
+ const trimmed = line.trim();
223
+ if (!trimmed.startsWith("{")) continue;
224
+ try {
225
+ const evt = JSON.parse(trimmed);
226
+ threadId =
227
+ evt.thread_id ?? evt.session_id ?? evt.thread?.id ?? threadId;
228
+ const item = evt.item ?? evt;
229
+ const type = item.item_type ?? item.type;
230
+ if (
231
+ (type === "agent_message" || type === "assistant_message") &&
232
+ typeof (item.text ?? item.message) === "string"
233
+ ) {
234
+ last = item.text ?? item.message;
235
+ }
236
+ } catch {
237
+ // not an event line — ignore
238
+ }
239
+ }
240
+ return {
241
+ text: cleanText(last ?? raw),
242
+ sessionId: mode === "session" ? namespaced(normalized, threadId) : null,
243
+ };
244
+ }
245
+
246
+ if (normalized === "gemini") {
247
+ // No reliable headless resume — never claim one. Session mode gets yolo
248
+ // approvals so its tools can run unattended.
249
+ const args = ["-p", prompt, "--output-format", "json"];
250
+ if (mode === "session") args.push("--yolo");
251
+ let raw;
252
+ try {
253
+ raw = await run(normalized, args, { timeoutMs, cwd });
254
+ } catch (err) {
255
+ // Older gemini builds lack --output-format; retry plain.
256
+ if (!/output-format|unknown option/i.test(String(err.message))) throw err;
257
+ raw = await run(normalized, ["-p", prompt], { timeoutMs, cwd });
258
+ return { text: cleanText(raw), sessionId: null };
259
+ }
260
+ try {
261
+ const parsed = JSON.parse(raw);
262
+ return {
263
+ text: cleanText(parsed.response ?? parsed.result ?? raw),
264
+ sessionId: null,
265
+ };
266
+ } catch {
267
+ return { text: cleanText(raw), sessionId: null };
268
+ }
269
+ }
270
+
271
+ // opencode — plain text out; we don't claim session continuation (it's
272
+ // version-dependent), so every turn replays history.
273
+ const raw = await run(normalized, ["run", prompt], { timeoutMs, cwd });
274
+ return { text: cleanText(raw), sessionId: null };
275
+ }
package/worker/agent.js CHANGED
@@ -12,8 +12,21 @@ import { BRAND_DIR } from "./paths.js";
12
12
  // Automated / no-reply senders never send brand deals — skip them WITHOUT
13
13
  // spending a Claude call. Conservative on purpose: real brands/agencies use
14
14
  // human addresses.
15
- const AUTOMATED_SENDER =
16
- /no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|notifications?@|accounts\.google\.com|@.*\.(beehiiv|substack)\.com|@e\.|@email\.|@mail\./i;
15
+ const AUTOMATED_SENDER = new RegExp(
16
+ [
17
+ // Classic no-reply shapes.
18
+ "no-?reply", "do-?not-?reply", "noreply", "mailer-daemon", "postmaster",
19
+ "notifications?@", "accounts\\.google\\.com",
20
+ "@.*\\.(beehiiv|substack)\\.com",
21
+ // Bulk-mail SUBDOMAINS. Retail blasts send from things like
22
+ // news.emailmarket.shein.com, which slipped past the old @mail./@email.
23
+ // patterns and cost a full Claude call each — 7 SHEIN cart reminders in
24
+ // one scan. Matched as a leading label so a real company domain like
25
+ // news-corp.com or mailchimp.com (an agency's own address) is untouched.
26
+ "@(e|news|newsletter|email|emails|mail|mailer|marketing|promo|promos|campaign|campaigns|updates|offers|deals|shop|store|hello|info)[.-]",
27
+ ].join("|"),
28
+ "i",
29
+ );
17
30
 
18
31
  export const isAutomated = (from) => AUTOMATED_SENDER.test(from);
19
32
 
package/worker/api.js CHANGED
@@ -45,7 +45,11 @@ export const TOKEN =
45
45
  localEnv.WORKER_TOKEN ??
46
46
  sharedEnv.WORKER_TOKEN;
47
47
 
48
- export const POLL_MS = 30_000;
48
+ // 5 min, not 30s: a 24/7 30s poll kept the prod database awake around the
49
+ // clock and burned its whole compute quota. Brand work is background inbox
50
+ // tending — nobody is watching a spinner — and the loop drains bursts
51
+ // immediately once one job is claimed, so only the first pickup waits.
52
+ export const POLL_MS = 5 * 60 * 1000;
49
53
 
50
54
  /** POST to /api/brand/worker/<path>. Returns null on 204 (no work). */
51
55
  export async function api(path, body) {
package/worker/claude.js CHANGED
@@ -1,77 +1,59 @@
1
- // Running Claude on this machine.
1
+ // Running the user's coding agent on this machine.
2
+ //
3
+ // Historically this file shelled out to Claude Code directly; the actual CLI
4
+ // plumbing now lives in agent-cli.js (vendored, shared across the Goose Tools
5
+ // workers) and honors the agent the user picked on goosetools.com — the claim
6
+ // payload's `agentCli`. index.js calls setAgent() once per job; the worker
7
+ // handles jobs serially, so module state is safe.
2
8
  //
3
9
  // Two modes, and the split matters for speed. brand-manager learned this the
4
- // hard way (commit c0c1b33): letting Claude loop on tools to gather its own
10
+ // hard way (commit c0c1b33): letting the agent loop on tools to gather its own
5
11
  // context took 5–10 minutes per draft versus ~36 seconds when everything was
6
12
  // inlined up front. So:
7
13
  //
8
14
  // oneShot() — drafting. Tools OFF, all context inlined by the caller. Fast.
9
15
  // session() — chat, voice-audit, stats, learn. Tools ON, scoped to the
10
- // brand directory, and resumable so a follow-up message
11
- // remembers the previous turn.
16
+ // brand directory, and resumable where the CLI supports it
17
+ // (Claude and Codex do; Gemini and OpenCode return a null
18
+ // sessionId, and callers replay history instead).
12
19
  //
13
- // There is no API key anywhere here. This runs on the user's own Claude
14
- // subscription via the `claude` CLI, same as every other Goose Tools worker.
20
+ // There is no API key anywhere here. This runs on the user's own agent
21
+ // subscription via that agent's CLI, same as every other Goose Tools worker.
15
22
 
16
- import { spawn } from "node:child_process";
23
+ import { runAgent, normalizeAgent, resolveSession } from "./agent-cli.js";
17
24
 
18
- const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
25
+ let currentAgent = "claude-code";
19
26
 
20
- // stdio[0] MUST be "ignore". Inherited stdin makes every invocation hang
21
- // forever under launchd — brand-manager hit this exact bug (fixed in 3b1f955)
22
- // and it presents as the worker silently doing nothing after a reboot.
23
- function run(args, { timeoutMs, cwd }) {
24
- return new Promise((resolve, reject) => {
25
- const child = spawn(CLAUDE_BIN, args, {
26
- cwd,
27
- stdio: ["ignore", "pipe", "pipe"],
28
- });
29
- let stdout = "";
30
- let stderr = "";
31
- child.stdout.on("data", (d) => (stdout += d));
32
- child.stderr.on("data", (d) => (stderr += d));
33
- const timer = setTimeout(() => {
34
- child.kill("SIGKILL");
35
- reject(new Error(`claude timed out after ${timeoutMs}ms`));
36
- }, timeoutMs);
37
- child.on("error", (err) => {
38
- clearTimeout(timer);
39
- reject(
40
- err.code === "ENOENT"
41
- ? new Error("Claude Code not found. Install: npm install -g @anthropic-ai/claude-code")
42
- : err,
43
- );
44
- });
45
- child.on("close", (code) => {
46
- clearTimeout(timer);
47
- if (code === 0) resolve(stdout);
48
- else reject(new Error(`claude exited ${code}: ${stderr.slice(0, 600)}`));
49
- });
50
- });
27
+ /** Set by index.js from the claim payload before each job's handler runs. */
28
+ export function setAgent(agentCli) {
29
+ currentAgent = normalizeAgent(agentCli) ?? "claude-code";
30
+ }
31
+
32
+ export function getAgent() {
33
+ return currentAgent;
51
34
  }
52
35
 
53
36
  /** Single-shot, no tools. For drafting, where the caller inlines all context. */
54
37
  export async function oneShot(prompt, { timeoutMs = 4 * 60 * 1000, cwd } = {}) {
55
- return run(
56
- [
57
- "-p",
58
- prompt,
59
- "--output-format",
60
- "text",
61
- "--disallowedTools",
62
- "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch",
63
- ],
64
- { timeoutMs, cwd },
65
- );
38
+ const { text } = await runAgent({
39
+ agent: currentAgent,
40
+ prompt,
41
+ cwd,
42
+ mode: "oneshot",
43
+ timeoutMs,
44
+ });
45
+ return text;
66
46
  }
67
47
 
68
48
  /**
69
- * A real, resumable Claude Code session with tools, scoped to the brand
70
- * directory. This is what makes the website chat behave like talking to the
71
- * agent in a terminal: it can read the ledgers, edit the rate card, search
72
- * Gmail through the worker's own commands, and remember the last turn.
49
+ * A real, resumable agent session with tools, scoped to the brand directory.
50
+ * This is what makes the website chat behave like talking to the agent in a
51
+ * terminal: it can read the ledgers, edit the rate card, and (where the CLI
52
+ * supports resuming) remember the last turn.
73
53
  *
74
- * Returns { text, sessionId } — pass sessionId back in to continue.
54
+ * Returns { text, sessionId } — pass sessionId back in to continue. The id is
55
+ * namespaced per agent; a stored id from a different agent is dropped and the
56
+ * caller's history-replay path covers the gap.
75
57
  */
76
58
  export async function session(
77
59
  prompt,
@@ -84,39 +66,33 @@ export async function session(
84
66
  tools = "Read,Edit,Write,Glob,Grep",
85
67
  } = {},
86
68
  ) {
87
- const args = [
88
- "-p",
69
+ return runAgent({
70
+ agent: currentAgent,
89
71
  prompt,
90
- "--output-format",
91
- "json",
92
- "--permission-mode",
93
- "acceptEdits",
94
- "--allowedTools",
72
+ cwd,
73
+ mode: "session",
74
+ sessionId,
95
75
  tools,
96
- ];
97
- if (sessionId) args.push("--resume", sessionId);
98
-
99
- const raw = await run(args, { timeoutMs, cwd });
76
+ timeoutMs,
77
+ });
78
+ }
100
79
 
101
- // --output-format json gives a result envelope carrying the session id.
102
- // Be forgiving: a shape change shouldn't lose the user's answer, so fall
103
- // back to treating the output as plain text.
104
- try {
105
- const parsed = JSON.parse(raw);
106
- return {
107
- text: parsed.result ?? parsed.text ?? raw,
108
- sessionId: parsed.session_id ?? parsed.sessionId ?? sessionId ?? null,
109
- };
110
- } catch {
111
- return { text: raw, sessionId: sessionId ?? null };
112
- }
80
+ /**
81
+ * Would `sessionId` actually resume under the current agent? Callers check
82
+ * this before building a prompt so they can inline conversation history when
83
+ * the answer is no (agent switched, or the CLI can't resume at all).
84
+ */
85
+ export function canResume(sessionId) {
86
+ if (!sessionId) return false;
87
+ if (currentAgent === "gemini" || currentAgent === "opencode") return false;
88
+ return resolveSession(currentAgent, sessionId) !== null;
113
89
  }
114
90
 
115
91
  /**
116
- * Pull a JSON object out of Claude's text output. Ported verbatim in spirit
117
- * from brand-manager's parseDecision: prefer a fenced block, else take the
118
- * first {...last }. Returns null on failure — the caller must treat that as
119
- * "retry later", never as "skip this thread".
92
+ * Pull a JSON object out of the agent's text output. Ported verbatim in
93
+ * spirit from brand-manager's parseDecision: prefer a fenced block, else take
94
+ * the first {...last }. Returns null on failure — the caller must treat that
95
+ * as "retry later", never as "skip this thread".
120
96
  */
121
97
  export function parseJson(text) {
122
98
  const fenced = text.match(/```json\s*([\s\S]*?)```/);
package/worker/cli.js CHANGED
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
15
15
 
16
16
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
17
 
18
- const command = ["install", "update", "status", "uninstall", "run"].includes(process.argv[2])
18
+ const command = ["install", "update", "status", "uninstall", "run", "doctor"].includes(process.argv[2])
19
19
  ? process.argv[2]
20
20
  : "run";
21
21
 
@@ -48,7 +48,10 @@ function has(cmd) {
48
48
  }
49
49
  }
50
50
 
51
- if (!has("claude")) {
51
+ // Claude Code is the default agent, so it's required to set up; the other
52
+ // agents (picked in Setup on goosetools.com) are checked lazily per job and
53
+ // reported by `doctor`.
54
+ if (command !== "doctor" && !has("claude")) {
52
55
  console.error(
53
56
  "\nClaude Code isn't installed yet (it writes your replies).\n" +
54
57
  "Install it with: npm install -g @anthropic-ai/claude-code\n" +
@@ -58,7 +61,15 @@ if (!has("claude")) {
58
61
  process.exit(1);
59
62
  }
60
63
 
61
- if (command === "install") {
64
+ if (command === "doctor") {
65
+ const { AGENT_IDS, detect } = await import("./agent-cli.js");
66
+ for (const id of AGENT_IDS) {
67
+ const d = detect(id);
68
+ console.log(
69
+ `${d.installed ? "✓" : "✗"} ${d.label}${d.installed ? "" : ` — install: ${d.hint}`}`,
70
+ );
71
+ }
72
+ } else if (command === "install") {
62
73
  const { install } = await import("./service.js");
63
74
  install({
64
75
  url: (flag("--url") ?? "https://goosetools.com").replace(/\/$/, ""),
@@ -12,7 +12,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { classify, decide, learnFromEdit } from "./agent.js";
14
14
  import { extractContracts } from "./attachments.js";
15
- import { session } from "./claude.js";
15
+ import { session, canResume } from "./claude.js";
16
16
  import { invalidateContext } from "./context.js";
17
17
  import { appendDraftLog, lastDraftFor } from "./drafts-log.js";
18
18
  import { createReplyDraft } from "./gmail-draft.js";
@@ -35,18 +35,6 @@ const noGmail = () => ({
35
35
  error: "Gmail isn't connected — connect your email on goosetools.com/dashboard/brand-manager",
36
36
  });
37
37
 
38
- /** Thread metadata rows for the mirror, from fetched threads. */
39
- const threadMeta = (threads) =>
40
- threads.map((t) => ({
41
- threadId: t.id,
42
- accountEmail: t.account,
43
- fromName: t.last.from.replace(/\s*<[^>]*>\s*/, "").trim() || null,
44
- fromEmail: (t.last.from.match(/<([^>]+)>/)?.[1] ?? t.last.from).trim() || null,
45
- subject: t.subject,
46
- lastMessageAt: t.last.date,
47
- hasUnsentDraft: t.hasUnsentDraft,
48
- }));
49
-
50
38
  /** Resolve decision attachment paths ("stats/screenshots/x.png") under BRAND_DIR. */
51
39
  const resolveAttachments = (paths = []) =>
52
40
  paths.map((p) => (p.startsWith("/") ? p : join(BRAND_DIR, p))).filter((p) => existsSync(p));
@@ -73,7 +61,7 @@ async function draftForThread(job, thread) {
73
61
  return {
74
62
  ok: true,
75
63
  summary: `skip — ${decision.summary}`,
76
- mirror: buildMirror({ threads: threadMeta([thread]) }),
64
+ mirror: buildMirror({ threads: [thread] }),
77
65
  };
78
66
  }
79
67
 
@@ -105,7 +93,7 @@ async function draftForThread(job, thread) {
105
93
  summary: decision.summary,
106
94
  resultBody: decision.body ?? "",
107
95
  flags: decision.flags ?? [],
108
- mirror: buildMirror({ threads: threadMeta([thread]) }),
96
+ mirror: buildMirror({ threads: [thread] }),
109
97
  };
110
98
  }
111
99
 
@@ -177,7 +165,7 @@ export async function runScan(job) {
177
165
  ok: true,
178
166
  summary: lines.length ? lines.join(" · ").slice(0, 1900) : "No new brand mail needing a draft.",
179
167
  flags,
180
- mirror: buildMirror({ threads: threadMeta(threads) }),
168
+ mirror: buildMirror({ threads }),
181
169
  };
182
170
  }
183
171
 
@@ -213,7 +201,7 @@ export async function runChat(job) {
213
201
  `base/rules/feedback-loop.md before saving any preference), voice/ playbook/ deals/ stats/ are`,
214
202
  `their personal layer — read what you need, and APPEND edits per the feedback loop.`,
215
203
  `You cannot send email, and you cannot access Gmail from this session.`,
216
- job.sessionId ? "" : history ? `\nConversation so far:\n${history}` : "",
204
+ canResume(job.sessionId) ? "" : history ? `\nConversation so far:\n${history}` : "",
217
205
  threadBlock,
218
206
  ``,
219
207
  `The creator says:`,
package/worker/index.js CHANGED
@@ -13,6 +13,7 @@ import { api, syncAssets, BASE_URL, POLL_MS, TOKEN } from "./api.js";
13
13
  import { BRAND_DIR, ensureDirs } from "./paths.js";
14
14
  import { buildMirror } from "./mirror.js";
15
15
  import { runOutreachChat, runOutreachDraft } from "./outreach.js";
16
+ import { setAgent } from "./claude.js";
16
17
  import {
17
18
  runChat,
18
19
  runDraft,
@@ -37,6 +38,9 @@ console.log(`brand worker → ${BASE_URL}`);
37
38
  console.log(`files → ${BRAND_DIR}`);
38
39
 
39
40
  async function handle(job) {
41
+ // The claim payload says which agent CLI the user picked; handlers and the
42
+ // claude.js delegates read it for the duration of this job.
43
+ setAgent(job.agentCli);
40
44
  switch (job.kind) {
41
45
  case "index": {
42
46
  // Pure local read — no Gmail needed, so this works with nothing else
package/worker/mirror.js CHANGED
@@ -74,15 +74,22 @@ export function collectKnowledge() {
74
74
  * machine is defined in exactly one place, right here.
75
75
  */
76
76
  export function collectThreads(threads) {
77
- return threads.map((t) => ({
78
- threadId: t.id,
79
- accountEmail: t.account ?? null,
80
- fromName: t.fromName ?? null,
81
- fromEmail: t.fromEmail ?? null,
82
- subject: t.subject ?? null,
83
- lastMessageAt: t.lastMessageAt ?? null,
84
- hasUnsentDraft: Boolean(t.hasUnsentDraft),
85
- }));
77
+ return threads.map((t) => {
78
+ // Raw Thread objects in, mirror rows out. This is the ONLY place that
79
+ // shape is decided — callers must not pre-map, or threadId silently
80
+ // becomes undefined and the server drops the row (which is exactly what
81
+ // happened: deals and knowledge mirrored, threads never did).
82
+ const from = t.last?.from ?? "";
83
+ return {
84
+ threadId: t.id,
85
+ accountEmail: t.account ?? null,
86
+ fromName: from.replace(/\s*<[^>]*>\s*/, "").replace(/^"|"$/g, "").trim() || null,
87
+ fromEmail: (from.match(/<([^>]+)>/)?.[1] ?? from).trim() || null,
88
+ subject: t.subject ?? null,
89
+ lastMessageAt: t.last?.date ?? null,
90
+ hasUnsentDraft: Boolean(t.hasUnsentDraft),
91
+ };
92
+ });
86
93
  }
87
94
 
88
95
  /** The full mirror payload for a complete() call. */
@@ -13,7 +13,7 @@
13
13
  import { existsSync, readFileSync, readdirSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { BASE_DIR, BRAND_DIR, PLAYBOOK_DIR, STATS_DIR, VOICE_DIR } from "./paths.js";
16
- import { oneShot, parseJson, session } from "./claude.js";
16
+ import { oneShot, parseJson, session, canResume } from "./claude.js";
17
17
  import { createGmailDraft } from "./gmail-draft.js";
18
18
 
19
19
  const read = (p, cap = 20_000) =>
@@ -60,7 +60,7 @@ function historyBlock(history) {
60
60
  */
61
61
  export async function runOutreachChat(job) {
62
62
  const prompt = `${outreachMode()}
63
- ${job.sessionId ? "" : historyBlock(job.history)}
63
+ ${canResume(job.sessionId) ? "" : historyBlock(job.history)}
64
64
  ## This turn
65
65
 
66
66
  The creator says: "${job.instruction ?? ""}"
package/worker/service.js CHANGED
@@ -62,7 +62,16 @@ function servicePath() {
62
62
  npmGlobalBin(),
63
63
  ...(process.platform === "win32"
64
64
  ? [process.env.PATH ?? ""]
65
- : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]),
65
+ : [
66
+ "/opt/homebrew/bin",
67
+ "/usr/local/bin",
68
+ // opencode's installer and some npm setups drop binaries here.
69
+ `${process.env.HOME ?? ""}/.local/bin`,
70
+ "/usr/bin",
71
+ "/bin",
72
+ "/usr/sbin",
73
+ "/sbin",
74
+ ]),
66
75
  ].filter(Boolean);
67
76
  return [...new Set(parts)].join(process.platform === "win32" ? ";" : ":");
68
77
  }