@heylemon/lemonade 0.0.9 → 0.1.1

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,76 @@
1
+ const BLOCKED_ENV_VAR_PATTERNS = [
2
+ /^ANTHROPIC_API_KEY$/i,
3
+ /^OPENAI_API_KEY$/i,
4
+ /^GEMINI_API_KEY$/i,
5
+ /^OPENROUTER_API_KEY$/i,
6
+ /^MINIMAX_API_KEY$/i,
7
+ /^ELEVENLABS_API_KEY$/i,
8
+ /^SYNTHETIC_API_KEY$/i,
9
+ /^TELEGRAM_BOT_TOKEN$/i,
10
+ /^DISCORD_BOT_TOKEN$/i,
11
+ /^SLACK_(BOT|APP)_TOKEN$/i,
12
+ /^LINE_CHANNEL_SECRET$/i,
13
+ /^LINE_CHANNEL_ACCESS_TOKEN$/i,
14
+ /^LEMONADE_GATEWAY_(TOKEN|PASSWORD)$/i,
15
+ /^AWS_(SECRET_ACCESS_KEY|SECRET_KEY|SESSION_TOKEN)$/i,
16
+ /^(GH|GITHUB)_TOKEN$/i,
17
+ /^(AZURE|AZURE_OPENAI|COHERE|AI_GATEWAY|OPENROUTER)_API_KEY$/i,
18
+ /_?(API_KEY|TOKEN|PASSWORD|PRIVATE_KEY|SECRET)$/i,
19
+ ];
20
+ const ALLOWED_ENV_VAR_PATTERNS = [
21
+ /^LANG$/,
22
+ /^LC_.*$/i,
23
+ /^PATH$/i,
24
+ /^HOME$/i,
25
+ /^USER$/i,
26
+ /^SHELL$/i,
27
+ /^TERM$/i,
28
+ /^TZ$/i,
29
+ /^NODE_ENV$/i,
30
+ ];
31
+ export function validateEnvVarValue(value) {
32
+ if (value.includes("\0")) {
33
+ return "Contains null bytes";
34
+ }
35
+ if (value.length > 32768) {
36
+ return "Value exceeds maximum length";
37
+ }
38
+ if (/^[A-Za-z0-9+/=]{80,}$/.test(value)) {
39
+ return "Value looks like base64-encoded credential data";
40
+ }
41
+ return undefined;
42
+ }
43
+ function matchesAnyPattern(value, patterns) {
44
+ return patterns.some((pattern) => pattern.test(value));
45
+ }
46
+ export function sanitizeEnvVars(envVars, options = {}) {
47
+ const allowed = {};
48
+ const blocked = [];
49
+ const warnings = [];
50
+ const blockedPatterns = [...BLOCKED_ENV_VAR_PATTERNS, ...(options.customBlockedPatterns ?? [])];
51
+ const allowedPatterns = [...ALLOWED_ENV_VAR_PATTERNS, ...(options.customAllowedPatterns ?? [])];
52
+ for (const [rawKey, value] of Object.entries(envVars)) {
53
+ const key = rawKey.trim();
54
+ if (!key) {
55
+ continue;
56
+ }
57
+ if (matchesAnyPattern(key, blockedPatterns)) {
58
+ blocked.push(key);
59
+ continue;
60
+ }
61
+ if (options.strictMode && !matchesAnyPattern(key, allowedPatterns)) {
62
+ blocked.push(key);
63
+ continue;
64
+ }
65
+ const warning = validateEnvVarValue(value);
66
+ if (warning) {
67
+ if (warning === "Contains null bytes") {
68
+ blocked.push(key);
69
+ continue;
70
+ }
71
+ warnings.push(`${key}: ${warning}`);
72
+ }
73
+ allowed[key] = value;
74
+ }
75
+ return { allowed, blocked, warnings };
76
+ }
@@ -1,36 +1,123 @@
1
+ import { sanitizeEnvVars, validateEnvVarValue } from "../sandbox/sanitize-env-vars.js";
1
2
  import { resolveSkillConfig } from "./config.js";
2
3
  import { resolveSkillKey } from "./frontmatter.js";
3
- export function applySkillEnvOverrides(params) {
4
- const { skills, config } = params;
5
- const updates = [];
6
- for (const entry of skills) {
7
- const skillKey = resolveSkillKey(entry.skill, entry);
8
- const skillConfig = resolveSkillConfig(config, skillKey);
9
- if (!skillConfig)
4
+ const HARD_BLOCKED_SKILL_ENV_PATTERNS = [
5
+ /^NODE_OPTIONS$/i,
6
+ /^OPENSSL_CONF$/i,
7
+ /^LD_PRELOAD$/i,
8
+ /^DYLD_INSERT_LIBRARIES$/i,
9
+ ];
10
+ function matchesAnyPattern(value, patterns) {
11
+ return patterns.some((pattern) => pattern.test(value));
12
+ }
13
+ function sanitizeSkillEnvOverrides(params) {
14
+ if (Object.keys(params.overrides).length === 0) {
15
+ return { allowed: {}, blocked: [], warnings: [] };
16
+ }
17
+ const result = sanitizeEnvVars(params.overrides, {
18
+ customBlockedPatterns: HARD_BLOCKED_SKILL_ENV_PATTERNS,
19
+ });
20
+ const allowed = { ...result.allowed };
21
+ const blocked = [];
22
+ const warnings = [...result.warnings];
23
+ for (const key of result.blocked) {
24
+ if (matchesAnyPattern(key, HARD_BLOCKED_SKILL_ENV_PATTERNS) ||
25
+ !params.allowedSensitiveKeys.has(key)) {
26
+ blocked.push(key);
10
27
  continue;
11
- if (skillConfig.env) {
12
- for (const [envKey, envValue] of Object.entries(skillConfig.env)) {
13
- if (!envValue || process.env[envKey])
14
- continue;
15
- updates.push({ key: envKey, prev: process.env[envKey] });
16
- process.env[envKey] = envValue;
28
+ }
29
+ const value = params.overrides[key];
30
+ if (!value) {
31
+ continue;
32
+ }
33
+ const warning = validateEnvVarValue(value);
34
+ if (warning) {
35
+ if (warning === "Contains null bytes") {
36
+ blocked.push(key);
37
+ continue;
17
38
  }
39
+ warnings.push(`${key}: ${warning}`);
18
40
  }
19
- const primaryEnv = entry.metadata?.primaryEnv;
20
- if (primaryEnv && skillConfig.apiKey && !process.env[primaryEnv]) {
21
- updates.push({ key: primaryEnv, prev: process.env[primaryEnv] });
22
- process.env[primaryEnv] = skillConfig.apiKey;
41
+ allowed[key] = value;
42
+ }
43
+ return { allowed, blocked, warnings };
44
+ }
45
+ function applySkillConfigEnvOverrides(params) {
46
+ const { updates, skillConfig, primaryEnv, requiredEnv, skillKey } = params;
47
+ const allowedSensitiveKeys = new Set();
48
+ const normalizedPrimaryEnv = primaryEnv?.trim();
49
+ if (normalizedPrimaryEnv) {
50
+ allowedSensitiveKeys.add(normalizedPrimaryEnv);
51
+ }
52
+ for (const envName of requiredEnv ?? []) {
53
+ const trimmedEnv = envName.trim();
54
+ if (trimmedEnv) {
55
+ allowedSensitiveKeys.add(trimmedEnv);
56
+ }
57
+ }
58
+ const pendingOverrides = {};
59
+ if (skillConfig.env) {
60
+ for (const [rawKey, envValue] of Object.entries(skillConfig.env)) {
61
+ const envKey = rawKey.trim();
62
+ if (!envKey || !envValue || process.env[envKey]) {
63
+ continue;
64
+ }
65
+ pendingOverrides[envKey] = envValue;
23
66
  }
24
67
  }
68
+ if (normalizedPrimaryEnv && skillConfig.apiKey && !process.env[normalizedPrimaryEnv]) {
69
+ if (!pendingOverrides[normalizedPrimaryEnv]) {
70
+ pendingOverrides[normalizedPrimaryEnv] = skillConfig.apiKey;
71
+ }
72
+ }
73
+ const sanitized = sanitizeSkillEnvOverrides({
74
+ overrides: pendingOverrides,
75
+ allowedSensitiveKeys,
76
+ });
77
+ if (sanitized.blocked.length > 0) {
78
+ console.warn(`[Security] Blocked skill env overrides for ${skillKey}:`, sanitized.blocked.join(", "));
79
+ }
80
+ if (sanitized.warnings.length > 0) {
81
+ console.warn(`[Security] Suspicious skill env overrides for ${skillKey}:`, sanitized.warnings);
82
+ }
83
+ for (const [envKey, envValue] of Object.entries(sanitized.allowed)) {
84
+ if (process.env[envKey]) {
85
+ continue;
86
+ }
87
+ updates.push({ key: envKey, prev: process.env[envKey] });
88
+ process.env[envKey] = envValue;
89
+ }
90
+ }
91
+ function createEnvReverter(updates) {
25
92
  return () => {
26
93
  for (const update of updates) {
27
- if (update.prev === undefined)
94
+ if (update.prev === undefined) {
28
95
  delete process.env[update.key];
29
- else
96
+ }
97
+ else {
30
98
  process.env[update.key] = update.prev;
99
+ }
31
100
  }
32
101
  };
33
102
  }
103
+ export function applySkillEnvOverrides(params) {
104
+ const { skills, config } = params;
105
+ const updates = [];
106
+ for (const entry of skills) {
107
+ const skillKey = resolveSkillKey(entry.skill, entry);
108
+ const skillConfig = resolveSkillConfig(config, skillKey);
109
+ if (!skillConfig)
110
+ continue;
111
+ applySkillConfigEnvOverrides({
112
+ updates,
113
+ skillConfig,
114
+ primaryEnv: entry.metadata?.primaryEnv,
115
+ requiredEnv: entry.metadata?.requires?.env,
116
+ skillKey,
117
+ });
118
+ }
119
+ return createEnvReverter(updates);
120
+ }
34
121
  export function applySkillEnvOverridesFromSnapshot(params) {
35
122
  const { snapshot, config } = params;
36
123
  if (!snapshot)
@@ -40,28 +127,13 @@ export function applySkillEnvOverridesFromSnapshot(params) {
40
127
  const skillConfig = resolveSkillConfig(config, skill.name);
41
128
  if (!skillConfig)
42
129
  continue;
43
- if (skillConfig.env) {
44
- for (const [envKey, envValue] of Object.entries(skillConfig.env)) {
45
- if (!envValue || process.env[envKey])
46
- continue;
47
- updates.push({ key: envKey, prev: process.env[envKey] });
48
- process.env[envKey] = envValue;
49
- }
50
- }
51
- if (skill.primaryEnv && skillConfig.apiKey && !process.env[skill.primaryEnv]) {
52
- updates.push({
53
- key: skill.primaryEnv,
54
- prev: process.env[skill.primaryEnv],
55
- });
56
- process.env[skill.primaryEnv] = skillConfig.apiKey;
57
- }
130
+ applySkillConfigEnvOverrides({
131
+ updates,
132
+ skillConfig,
133
+ primaryEnv: skill.primaryEnv,
134
+ requiredEnv: skill.requiredEnv,
135
+ skillKey: skill.name,
136
+ });
58
137
  }
59
- return () => {
60
- for (const update of updates) {
61
- if (update.prev === undefined)
62
- delete process.env[update.key];
63
- else
64
- process.env[update.key] = update.prev;
65
- }
66
- };
138
+ return createEnvReverter(updates);
67
139
  }
@@ -147,6 +147,7 @@ export function buildWorkspaceSkillSnapshot(workspaceDir, opts) {
147
147
  skills: eligible.map((entry) => ({
148
148
  name: entry.skill.name,
149
149
  primaryEnv: entry.metadata?.primaryEnv,
150
+ requiredEnv: entry.metadata?.requires?.env,
150
151
  })),
151
152
  resolvedSkills,
152
153
  version: opts?.snapshotVersion,
@@ -32,7 +32,7 @@ function buildMemorySection(params) {
32
32
  function buildUserIdentitySection(ownerLine, isMinimal) {
33
33
  if (!ownerLine || isMinimal)
34
34
  return [];
35
- return ["## User Identity", ownerLine, ""];
35
+ return ["## Authorized Senders", ownerLine, ""];
36
36
  }
37
37
  function buildTimeSection(params) {
38
38
  if (!params.userTimezone)
@@ -231,7 +231,7 @@ export function buildAgentSystemPrompt(params) {
231
231
  const extraSystemPrompt = params.extraSystemPrompt?.trim();
232
232
  const ownerNumbers = (params.ownerNumbers ?? []).map((value) => value.trim()).filter(Boolean);
233
233
  const ownerLine = ownerNumbers.length > 0
234
- ? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user.`
234
+ ? `Authorized senders: ${ownerNumbers.join(", ")}. These senders are allowlisted; do not assume they are the owner.`
235
235
  : undefined;
236
236
  const reasoningHint = params.reasoningTagHint
237
237
  ? [
@@ -317,6 +317,45 @@ export function buildAgentSystemPrompt(params) {
317
317
  "Never reply with a list of approaches/options when a single tool call would suffice.",
318
318
  "For screenshots of native macOS windows: use Peekaboo (`peekaboo image`) via exec if the skill is available.",
319
319
  "",
320
+ "## URLs & Quick Open",
321
+ "When the user wants to *see* a page, video, or search result — open it in their browser. Don't over-automate simple requests.",
322
+ "",
323
+ "### Decision tree",
324
+ "1. User wants to *view* something (website, video, search results) → `open` the URL via exec.",
325
+ "2. User wants an *answer* from the web → use `web_search` (+ `web_fetch` if needed).",
326
+ "3. User wants to *interact* with a page (fill forms, click buttons, scrape data) → use the `browser` tool.",
327
+ "",
328
+ "### Opening URLs (macOS)",
329
+ "Use `exec` with the macOS `open` command to launch URLs in the user's default browser:",
330
+ '- `open "https://example.com"` — opens in default browser.',
331
+ '- `open -a "Google Chrome" "https://example.com"` — opens in a specific browser.',
332
+ 'This is the fastest path for simple requests like "open YouTube" or "show me Amazon".',
333
+ "",
334
+ "### Common search/action URIs",
335
+ "Build the URL directly instead of navigating and typing into search boxes:",
336
+ "| Task | URI pattern |",
337
+ "|------|------------|",
338
+ "| Google search | `https://google.com/search?q={query}` |",
339
+ "| YouTube search | `https://youtube.com/results?search_query={query}` |",
340
+ "| Google Maps search | `https://maps.google.com/maps?q={query}` |",
341
+ "| Google Maps directions | `https://maps.google.com/maps/dir/{from}/{to}` |",
342
+ "| Amazon search | `https://amazon.com/s?k={query}` |",
343
+ "| Wikipedia | `https://en.wikipedia.org/wiki/{topic}` |",
344
+ "| Google Images | `https://google.com/search?tbm=isch&q={query}` |",
345
+ "| Google News | `https://news.google.com/search?q={query}` |",
346
+ "| Google Flights | `https://google.com/travel/flights?q={query}` |",
347
+ "| Reddit search | `https://reddit.com/search/?q={query}` |",
348
+ "| Twitter/X search | `https://x.com/search?q={query}` |",
349
+ "| LinkedIn search | `https://linkedin.com/search/results/all/?keywords={query}` |",
350
+ "URL-encode the query (`%20` for spaces or use `+`).",
351
+ "",
352
+ "### Examples",
353
+ '- "search YouTube for funny cats" → `open "https://youtube.com/results?search_query=funny+cats"`',
354
+ '- "show me flights to Tokyo" → `open "https://google.com/travel/flights?q=flights+to+tokyo"`',
355
+ '- "open amazon" → `open "https://amazon.com"`',
356
+ '- "find Italian restaurants near me" → `open "https://maps.google.com/maps?q=italian+restaurants+near+me"`',
357
+ '- "what is the capital of France?" → use `web_search` (user wants the answer, not a page)',
358
+ "",
320
359
  "## IDE & Coding Agent Control",
321
360
  "Control AI coding agents via CLI — never type into GUI windows:",
322
361
  "- Terminal agents (Claude Code, Codex, Pi): run via `bash pty:true` with `background:true` for long tasks. Monitor with `process action:log`.",
@@ -509,6 +548,7 @@ export function buildAgentSystemPrompt(params) {
509
548
  if (!isMinimal) {
510
549
  lines.push("## Heartbeats", heartbeatPromptLine, "If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:", "HEARTBEAT_OK", 'Lemonade treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).', 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', "");
511
550
  }
551
+ lines.push("## Safety", "You have no independent goals: do not pursue self-preservation, replication, resource acquisition, or power-seeking; avoid long-term plans beyond the user's request.", "Prioritize safety and human oversight over completion; if instructions conflict, pause and ask; comply with stop/pause/audit requests and never bypass safeguards.", "Do not manipulate or persuade anyone to expand access or disable safeguards. Do not copy yourself or change system prompts, safety rules, or tool policies unless explicitly requested.", "");
512
552
  lines.push("## Runtime", buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel), `Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`);
513
553
  return lines.filter(Boolean).join("\n");
514
554
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.9",
3
- "commit": "a7a899fbe3b6faf9d612cb1e17c04c84035f02aa",
4
- "builtAt": "2026-02-20T07:57:12.714Z"
2
+ "version": "0.1.1",
3
+ "commit": "c8a249c7139f4bab7fe16d65fb0e565384f6e6ae",
4
+ "builtAt": "2026-02-20T22:53:16.169Z"
5
5
  }
@@ -1 +1 @@
1
- b3f5fa0f921d2d5f4c3f96081e3cca7ec3382c9223c7dea1eb1b7047d2bf267c
1
+ 4ad41ef1417592c4619976cb95999af4828d9438481c0c3a0bb43a06440cb7b2
@@ -1,7 +1,7 @@
1
1
  import { applyJobPatch, computeJobNextRunAtMs, createJob, findJobOrThrow, isJobDue, nextWakeAtMs, recomputeNextRuns, } from "./jobs.js";
2
2
  import { locked } from "./locked.js";
3
3
  import { ensureLoaded, forceReload, persist, startFileWatcher, stopFileWatcher, warnIfDisabled, } from "./store.js";
4
- import { armTimer, emit, executeJob, stopTimer, wake } from "./timer.js";
4
+ import { armTimer, emit, executeJob, runMissedJobs, stopTimer, wake } from "./timer.js";
5
5
  export async function start(state) {
6
6
  await locked(state, async () => {
7
7
  if (!state.deps.cronEnabled) {
@@ -9,6 +9,18 @@ export async function start(state) {
9
9
  return;
10
10
  }
11
11
  await ensureLoaded(state);
12
+ // Clear stale running markers from jobs interrupted by a crash
13
+ const jobs = state.store?.jobs ?? [];
14
+ const startupInterruptedJobIds = new Set();
15
+ for (const job of jobs) {
16
+ if (typeof job.state.runningAtMs === "number") {
17
+ state.deps.log.warn({ jobId: job.id, runningAtMs: job.state.runningAtMs }, "cron: clearing stale running marker on startup");
18
+ job.state.runningAtMs = undefined;
19
+ startupInterruptedJobIds.add(job.id);
20
+ }
21
+ }
22
+ // Run missed jobs (skip ones that were interrupted mid-execution)
23
+ await runMissedJobs(state, { skipJobIds: startupInterruptedJobIds });
12
24
  recomputeNextRuns(state);
13
25
  await persist(state);
14
26
  armTimer(state);
@@ -2,6 +2,31 @@ import { computeJobNextRunAtMs, nextWakeAtMs, resolveJobPayloadTextForMain } fro
2
2
  import { locked } from "./locked.js";
3
3
  import { ensureLoaded, persist } from "./store.js";
4
4
  const MAX_TIMEOUT_MS = 2 ** 31 - 1;
5
+ /**
6
+ * Maximum wall-clock time between consecutive timer fires.
7
+ * Ensures the scheduler wakes at least once a minute to avoid drift.
8
+ */
9
+ const MAX_TIMER_DELAY_MS = 60_000;
10
+ /**
11
+ * Minimum gap between consecutive fires of the same cron job.
12
+ * Prevents spin-loops when `computeJobNextRunAtMs` returns a value
13
+ * within the same second as the just-completed run.
14
+ */
15
+ const MIN_REFIRE_GAP_MS = 2_000;
16
+ /**
17
+ * Exponential backoff delays (in ms) indexed by consecutive error count.
18
+ */
19
+ const ERROR_BACKOFF_SCHEDULE_MS = [
20
+ 30_000, // 1st error → 30 s
21
+ 60_000, // 2nd error → 1 min
22
+ 5 * 60_000, // 3rd error → 5 min
23
+ 15 * 60_000, // 4th error → 15 min
24
+ 60 * 60_000, // 5th+ error → 60 min
25
+ ];
26
+ function errorBackoffMs(consecutiveErrors) {
27
+ const idx = Math.min(consecutiveErrors - 1, ERROR_BACKOFF_SCHEDULE_MS.length - 1);
28
+ return ERROR_BACKOFF_SCHEDULE_MS[Math.max(0, idx)];
29
+ }
5
30
  export function armTimer(state) {
6
31
  if (state.timer)
7
32
  clearTimeout(state.timer);
@@ -12,8 +37,7 @@ export function armTimer(state) {
12
37
  if (!nextAt)
13
38
  return;
14
39
  const delay = Math.max(nextAt - state.deps.nowMs(), 0);
15
- // Avoid TimeoutOverflowWarning when a job is far in the future.
16
- const clampedDelay = Math.min(delay, MAX_TIMEOUT_MS);
40
+ const clampedDelay = Math.min(delay, MAX_TIMER_DELAY_MS);
17
41
  state.timer = setTimeout(() => {
18
42
  void onTimer(state).catch((err) => {
19
43
  state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
@@ -22,8 +46,20 @@ export function armTimer(state) {
22
46
  state.timer.unref?.();
23
47
  }
24
48
  export async function onTimer(state) {
25
- if (state.running)
49
+ if (state.running) {
50
+ // Re-arm the timer so the scheduler keeps ticking even when a job is
51
+ // still executing. Without this, a long-running job causes the clamped
52
+ // timer to fire while `running` is true. The early return then leaves
53
+ // no timer set, silently killing the scheduler until the next restart.
54
+ if (state.timer)
55
+ clearTimeout(state.timer);
56
+ state.timer = setTimeout(() => {
57
+ void onTimer(state).catch((err) => {
58
+ state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
59
+ });
60
+ }, MAX_TIMER_DELAY_MS);
26
61
  return;
62
+ }
27
63
  state.running = true;
28
64
  try {
29
65
  await locked(state, async () => {
@@ -53,6 +89,31 @@ export async function runDueJobs(state) {
53
89
  await executeJob(state, job, now, { forced: false });
54
90
  }
55
91
  }
92
+ export async function runMissedJobs(state, opts) {
93
+ if (!state.store)
94
+ return;
95
+ const now = state.deps.nowMs();
96
+ const skipJobIds = opts?.skipJobIds;
97
+ const missed = state.store.jobs.filter((j) => {
98
+ if (!j.enabled)
99
+ return false;
100
+ if (skipJobIds?.has(j.id))
101
+ return false;
102
+ if (typeof j.state.runningAtMs === "number")
103
+ return false;
104
+ // Skip one-shot jobs that already ran
105
+ if (j.schedule.kind === "at" && j.state.lastStatus)
106
+ return false;
107
+ const next = j.state.nextRunAtMs;
108
+ return typeof next === "number" && now >= next;
109
+ });
110
+ if (missed.length > 0) {
111
+ state.deps.log.info({ count: missed.length, jobIds: missed.map((j) => j.id) }, "cron: running missed jobs after restart");
112
+ for (const job of missed) {
113
+ await executeJob(state, job, now, { forced: false });
114
+ }
115
+ }
116
+ }
56
117
  export async function executeJob(state, job, nowMs, opts) {
57
118
  const startedAt = state.deps.nowMs();
58
119
  job.state.runningAtMs = startedAt;
@@ -66,15 +127,43 @@ export async function executeJob(state, job, nowMs, opts) {
66
127
  job.state.lastStatus = status;
67
128
  job.state.lastDurationMs = Math.max(0, endedAt - startedAt);
68
129
  job.state.lastError = err;
130
+ // Track consecutive errors for backoff
131
+ if (status === "error") {
132
+ job.state.consecutiveErrors = (job.state.consecutiveErrors ?? 0) + 1;
133
+ }
134
+ else {
135
+ job.state.consecutiveErrors = 0;
136
+ }
69
137
  const shouldDelete = job.schedule.kind === "at" && status === "ok" && job.deleteAfterRun === true;
70
138
  if (!shouldDelete) {
71
- if (job.schedule.kind === "at" && status === "ok") {
72
- // One-shot job completed successfully; disable it.
139
+ if (job.schedule.kind === "at") {
73
140
  job.enabled = false;
74
141
  job.state.nextRunAtMs = undefined;
75
142
  }
143
+ else if (status === "error" && job.enabled) {
144
+ // Apply exponential backoff for errored jobs to prevent retry storms
145
+ const backoff = errorBackoffMs(job.state.consecutiveErrors ?? 1);
146
+ const normalNext = computeJobNextRunAtMs(job, endedAt);
147
+ const backoffNext = endedAt + backoff;
148
+ job.state.nextRunAtMs =
149
+ normalNext !== undefined ? Math.max(normalNext, backoffNext) : backoffNext;
150
+ state.deps.log.info({
151
+ jobId: job.id,
152
+ consecutiveErrors: job.state.consecutiveErrors,
153
+ backoffMs: backoff,
154
+ nextRunAtMs: job.state.nextRunAtMs,
155
+ }, "cron: applying error backoff");
156
+ }
76
157
  else if (job.enabled) {
77
- job.state.nextRunAtMs = computeJobNextRunAtMs(job, endedAt);
158
+ const naturalNext = computeJobNextRunAtMs(job, endedAt);
159
+ if (job.schedule.kind === "cron") {
160
+ const minNext = endedAt + MIN_REFIRE_GAP_MS;
161
+ job.state.nextRunAtMs =
162
+ naturalNext !== undefined ? Math.max(naturalNext, minNext) : minNext;
163
+ }
164
+ else {
165
+ job.state.nextRunAtMs = naturalNext;
166
+ }
78
167
  }
79
168
  else {
80
169
  job.state.nextRunAtMs = undefined;
@@ -0,0 +1,2 @@
1
+ /** Default timeout for iMessage probe/RPC operations (10 seconds). */
2
+ export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000;
@@ -0,0 +1,23 @@
1
+ export function attachIMessageMonitorAbortHandler(params) {
2
+ const abort = params.abortSignal;
3
+ if (!abort) {
4
+ return () => { };
5
+ }
6
+ const onAbort = () => {
7
+ const subscriptionId = params.getSubscriptionId();
8
+ if (subscriptionId) {
9
+ void params.client
10
+ .request("watch.unsubscribe", {
11
+ subscription: subscriptionId,
12
+ })
13
+ .catch(() => {
14
+ // Ignore disconnect errors during shutdown.
15
+ });
16
+ }
17
+ void params.client.stop().catch(() => {
18
+ // Ignore disconnect errors during shutdown.
19
+ });
20
+ };
21
+ abort.addEventListener("abort", onAbort, { once: true });
22
+ return () => abort.removeEventListener("abort", onAbort);
23
+ }
@@ -4,7 +4,8 @@ import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
4
4
  import { convertMarkdownTables } from "../../markdown/tables.js";
5
5
  import { sendMessageIMessage } from "../send.js";
6
6
  export async function deliverReplies(params) {
7
- const { replies, target, client, runtime, maxBytes, textLimit, accountId } = params;
7
+ const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params;
8
+ const scope = `${accountId ?? ""}:${target}`;
8
9
  const cfg = loadConfig();
9
10
  const tableMode = resolveMarkdownTableMode({
10
11
  cfg,
@@ -19,12 +20,14 @@ export async function deliverReplies(params) {
19
20
  if (!text && mediaList.length === 0)
20
21
  continue;
21
22
  if (mediaList.length === 0) {
23
+ sentMessageCache?.remember(scope, text);
22
24
  for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
23
25
  await sendMessageIMessage(target, chunk, {
24
26
  maxBytes,
25
27
  client,
26
28
  accountId,
27
29
  });
30
+ sentMessageCache?.remember(scope, chunk);
28
31
  }
29
32
  }
30
33
  else {
@@ -38,6 +41,9 @@ export async function deliverReplies(params) {
38
41
  client,
39
42
  accountId,
40
43
  });
44
+ if (caption) {
45
+ sentMessageCache?.remember(scope, caption);
46
+ }
41
47
  }
42
48
  }
43
49
  runtime.log?.(`imessage: delivered reply to ${target}`);
@@ -25,10 +25,13 @@ import { truncateUtf16Safe } from "../../utils.js";
25
25
  import { resolveControlCommandGate } from "../../channels/command-gating.js";
26
26
  import { resolveIMessageAccount } from "../accounts.js";
27
27
  import { createIMessageRpcClient } from "../client.js";
28
+ import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
28
29
  import { probeIMessage } from "../probe.js";
29
30
  import { sendMessageIMessage } from "../send.js";
30
31
  import { formatIMessageChatTarget, isAllowedIMessageSender, normalizeIMessageHandle, } from "../targets.js";
32
+ import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
31
33
  import { deliverReplies } from "./deliver.js";
34
+ import { parseIMessageNotification } from "./parse-notification.js";
32
35
  import { normalizeAllowList, resolveRuntime } from "./runtime.js";
33
36
  /**
34
37
  * Try to detect remote host from an SSH wrapper script like:
@@ -55,6 +58,47 @@ async function detectRemoteHostFromCliPath(cliPath) {
55
58
  return undefined;
56
59
  }
57
60
  }
61
+ /**
62
+ * Cache for recently sent messages, used for echo detection.
63
+ * Keys are scoped by conversation (accountId:target) so the same text in different chats is not conflated.
64
+ * Entries expire after 5 seconds; we do not forget on match so multiple echo deliveries are all filtered.
65
+ */
66
+ class SentMessageCache {
67
+ cache = new Map();
68
+ ttlMs = 5000;
69
+ remember(scope, text) {
70
+ if (!text?.trim()) {
71
+ return;
72
+ }
73
+ const key = `${scope}:${text.trim()}`;
74
+ this.cache.set(key, Date.now());
75
+ this.cleanup();
76
+ }
77
+ has(scope, text) {
78
+ if (!text?.trim()) {
79
+ return false;
80
+ }
81
+ const key = `${scope}:${text.trim()}`;
82
+ const timestamp = this.cache.get(key);
83
+ if (!timestamp) {
84
+ return false;
85
+ }
86
+ const age = Date.now() - timestamp;
87
+ if (age > this.ttlMs) {
88
+ this.cache.delete(key);
89
+ return false;
90
+ }
91
+ return true;
92
+ }
93
+ cleanup() {
94
+ const now = Date.now();
95
+ for (const [text, timestamp] of this.cache.entries()) {
96
+ if (now - timestamp > this.ttlMs) {
97
+ this.cache.delete(text);
98
+ }
99
+ }
100
+ }
101
+ }
58
102
  function normalizeReplyField(value) {
59
103
  if (typeof value === "string") {
60
104
  const trimmed = value.trim();
@@ -84,6 +128,7 @@ export async function monitorIMessageProvider(opts = {}) {
84
128
  cfg.messages?.groupChat?.historyLimit ??
85
129
  DEFAULT_GROUP_HISTORY_LIMIT);
86
130
  const groupHistories = new Map();
131
+ const sentMessageCache = new SentMessageCache();
87
132
  const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId);
88
133
  const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
89
134
  const groupAllowFrom = normalizeAllowList(opts.groupAllowFrom ??
@@ -96,6 +141,7 @@ export async function monitorIMessageProvider(opts = {}) {
96
141
  const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;
97
142
  const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg";
98
143
  const dbPath = opts.dbPath ?? imessageCfg.dbPath;
144
+ const probeTimeoutMs = imessageCfg.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
99
145
  // Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script
100
146
  let remoteHost = imessageCfg.remoteHost;
101
147
  if (!remoteHost && cliPath && cliPath !== "imsg") {
@@ -155,6 +201,13 @@ export async function monitorIMessageProvider(opts = {}) {
155
201
  const senderNormalized = normalizeIMessageHandle(sender);
156
202
  if (message.is_from_me)
157
203
  return;
204
+ // Echo detection: skip messages that match recently sent replies
205
+ const echoScope = `${accountInfo.accountId ?? ""}:${sender}`;
206
+ const messageText0 = (message.text ?? "").trim();
207
+ if (messageText0 && sentMessageCache.has(echoScope, messageText0)) {
208
+ logVerbose(`imessage: dropping echo from ${sender}`);
209
+ return;
210
+ }
158
211
  const chatId = message.chat_id ?? undefined;
159
212
  const chatGuid = message.chat_guid ?? undefined;
160
213
  const chatIdentifier = message.chat_identifier ?? undefined;
@@ -482,6 +535,7 @@ export async function monitorIMessageProvider(opts = {}) {
482
535
  runtime,
483
536
  maxBytes: mediaMaxBytes,
484
537
  textLimit,
538
+ sentMessageCache,
485
539
  });
486
540
  },
487
541
  onError: (err, info) => {
@@ -514,10 +568,11 @@ export async function monitorIMessageProvider(opts = {}) {
514
568
  }
515
569
  }
516
570
  const handleMessage = async (raw) => {
517
- const params = raw;
518
- const message = params?.message ?? null;
519
- if (!message)
571
+ const message = parseIMessageNotification(raw);
572
+ if (!message) {
573
+ logVerbose("imessage: dropping malformed RPC message payload");
520
574
  return;
575
+ }
521
576
  await inboundDebouncer.enqueue({ message });
522
577
  };
523
578
  await waitForTransportReady({
@@ -529,7 +584,7 @@ export async function monitorIMessageProvider(opts = {}) {
529
584
  abortSignal: opts.abortSignal,
530
585
  runtime,
531
586
  check: async () => {
532
- const probe = await probeIMessage(2000, { cliPath, dbPath, runtime });
587
+ const probe = await probeIMessage(probeTimeoutMs, { cliPath, dbPath, runtime });
533
588
  if (probe.ok)
534
589
  return { ok: true };
535
590
  if (probe.fatal) {
@@ -557,21 +612,11 @@ export async function monitorIMessageProvider(opts = {}) {
557
612
  });
558
613
  let subscriptionId = null;
559
614
  const abort = opts.abortSignal;
560
- const onAbort = () => {
561
- if (subscriptionId) {
562
- void client
563
- .request("watch.unsubscribe", {
564
- subscription: subscriptionId,
565
- })
566
- .catch(() => {
567
- // Ignore disconnect errors during shutdown.
568
- });
569
- }
570
- void client.stop().catch(() => {
571
- // Ignore disconnect errors during shutdown.
572
- });
573
- };
574
- abort?.addEventListener("abort", onAbort, { once: true });
615
+ const detachAbortHandler = attachIMessageMonitorAbortHandler({
616
+ abortSignal: abort,
617
+ client,
618
+ getSubscriptionId: () => subscriptionId,
619
+ });
575
620
  try {
576
621
  const result = await client.request("watch.subscribe", {
577
622
  attachments: includeAttachments,
@@ -586,7 +631,7 @@ export async function monitorIMessageProvider(opts = {}) {
586
631
  throw err;
587
632
  }
588
633
  finally {
589
- abort?.removeEventListener("abort", onAbort);
634
+ detachAbortHandler();
590
635
  await client.stop();
591
636
  }
592
637
  }
@@ -0,0 +1,64 @@
1
+ function isRecord(value) {
2
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+ function isOptionalString(value) {
5
+ return value === undefined || value === null || typeof value === "string";
6
+ }
7
+ function isOptionalStringOrNumber(value) {
8
+ return (value === undefined || value === null || typeof value === "string" || typeof value === "number");
9
+ }
10
+ function isOptionalNumber(value) {
11
+ return value === undefined || value === null || typeof value === "number";
12
+ }
13
+ function isOptionalBoolean(value) {
14
+ return value === undefined || value === null || typeof value === "boolean";
15
+ }
16
+ function isOptionalStringArray(value) {
17
+ return (value === undefined ||
18
+ value === null ||
19
+ (Array.isArray(value) && value.every((entry) => typeof entry === "string")));
20
+ }
21
+ function isOptionalAttachments(value) {
22
+ if (value === undefined || value === null) {
23
+ return true;
24
+ }
25
+ if (!Array.isArray(value)) {
26
+ return false;
27
+ }
28
+ return value.every((attachment) => {
29
+ if (!isRecord(attachment)) {
30
+ return false;
31
+ }
32
+ return (isOptionalString(attachment.original_path) &&
33
+ isOptionalString(attachment.mime_type) &&
34
+ isOptionalBoolean(attachment.missing));
35
+ });
36
+ }
37
+ export function parseIMessageNotification(raw) {
38
+ if (!isRecord(raw)) {
39
+ return null;
40
+ }
41
+ const maybeMessage = raw.message;
42
+ if (!isRecord(maybeMessage)) {
43
+ return null;
44
+ }
45
+ const message = maybeMessage;
46
+ if (!isOptionalNumber(message.id) ||
47
+ !isOptionalNumber(message.chat_id) ||
48
+ !isOptionalString(message.sender) ||
49
+ !isOptionalBoolean(message.is_from_me) ||
50
+ !isOptionalString(message.text) ||
51
+ !isOptionalStringOrNumber(message.reply_to_id) ||
52
+ !isOptionalString(message.reply_to_text) ||
53
+ !isOptionalString(message.reply_to_sender) ||
54
+ !isOptionalString(message.created_at) ||
55
+ !isOptionalAttachments(message.attachments) ||
56
+ !isOptionalString(message.chat_identifier) ||
57
+ !isOptionalString(message.chat_guid) ||
58
+ !isOptionalString(message.chat_name) ||
59
+ !isOptionalStringArray(message.participants) ||
60
+ !isOptionalBoolean(message.is_group)) {
61
+ return null;
62
+ }
63
+ return message;
64
+ }
@@ -2,13 +2,16 @@ import { detectBinary } from "../commands/onboard-helpers.js";
2
2
  import { loadConfig } from "../config/config.js";
3
3
  import { runCommandWithTimeout } from "../process/exec.js";
4
4
  import { createIMessageRpcClient } from "./client.js";
5
+ import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
6
+ // Re-export for backwards compatibility
7
+ export { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
5
8
  const rpcSupportCache = new Map();
6
- async function probeRpcSupport(cliPath) {
9
+ async function probeRpcSupport(cliPath, timeoutMs) {
7
10
  const cached = rpcSupportCache.get(cliPath);
8
11
  if (cached)
9
12
  return cached;
10
13
  try {
11
- const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs: 2000 });
14
+ const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs });
12
15
  const combined = `${result.stdout}\n${result.stderr}`.trim();
13
16
  const normalized = combined.toLowerCase();
14
17
  if (normalized.includes("unknown command") && normalized.includes("rpc")) {
@@ -34,7 +37,12 @@ async function probeRpcSupport(cliPath) {
34
37
  return { supported: false, error: String(err) };
35
38
  }
36
39
  }
37
- export async function probeIMessage(timeoutMs = 2000, opts = {}) {
40
+ /**
41
+ * Probe iMessage RPC availability.
42
+ * @param timeoutMs - Explicit timeout in ms. If undefined, uses config or default.
43
+ * @param opts - Additional options (cliPath, dbPath, runtime).
44
+ */
45
+ export async function probeIMessage(timeoutMs, opts = {}) {
38
46
  const cfg = opts.cliPath || opts.dbPath ? undefined : loadConfig();
39
47
  const cliPath = opts.cliPath?.trim() || cfg?.channels?.imessage?.cliPath?.trim() || "imsg";
40
48
  const dbPath = opts.dbPath?.trim() || cfg?.channels?.imessage?.dbPath?.trim();
@@ -42,7 +50,8 @@ export async function probeIMessage(timeoutMs = 2000, opts = {}) {
42
50
  if (!detected) {
43
51
  return { ok: false, error: `imsg not found (${cliPath})` };
44
52
  }
45
- const rpcSupport = await probeRpcSupport(cliPath);
53
+ const effectiveTimeout = timeoutMs ?? cfg?.channels?.imessage?.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
54
+ const rpcSupport = await probeRpcSupport(cliPath, effectiveTimeout);
46
55
  if (!rpcSupport.supported) {
47
56
  return {
48
57
  ok: false,
@@ -56,7 +65,7 @@ export async function probeIMessage(timeoutMs = 2000, opts = {}) {
56
65
  runtime: opts.runtime,
57
66
  });
58
67
  try {
59
- await client.request("chats.list", { limit: 1 }, { timeoutMs });
68
+ await client.request("chats.list", { limit: 1 }, { timeoutMs: effectiveTimeout });
60
69
  return { ok: true };
61
70
  }
62
71
  catch (err) {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Secret normalization for copy/pasted credentials.
3
+ *
4
+ * Common footgun: line breaks (especially `\r`) embedded in API keys/tokens.
5
+ * We strip line breaks anywhere, then trim whitespace at the ends.
6
+ *
7
+ * Intentionally does NOT remove ordinary spaces inside the string to avoid
8
+ * silently altering "Bearer <token>" style values.
9
+ */
10
+ export function normalizeSecretInput(value) {
11
+ if (typeof value !== "string") {
12
+ return "";
13
+ }
14
+ return value.replace(/[\r\n\u2028\u2029]+/g, "").trim();
15
+ }
16
+ export function normalizeOptionalSecretInput(value) {
17
+ const normalized = normalizeSecretInput(value);
18
+ return normalized ? normalized : undefined;
19
+ }
@@ -1,7 +1,6 @@
1
1
  import { loadConfig } from "../../config/config.js";
2
2
  import { logVerbose } from "../../globals.js";
3
3
  import { emitAgentEvent } from "../../infra/agent-events.js";
4
- import { buildPairingReply } from "../../pairing/pairing-messages.js";
5
4
  import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../../pairing/pairing-store.js";
6
5
  import { isSelfChatMode, normalizeE164 } from "../../utils.js";
7
6
  import { resolveWhatsAppAccount } from "../accounts.js";
@@ -120,15 +119,15 @@ export async function checkInboundAccessControl(params) {
120
119
  data: {
121
120
  type: "pairing",
122
121
  channel: "whatsapp",
123
- title: "Lemonade Access Request",
124
- body: `${displayName} is requesting access to your Lemon via WhatsApp.`,
122
+ title: "WhatsApp Message",
123
+ body: `${displayName} sent a message. Add them in Settings to let Lemon respond.`,
125
124
  name: displayName,
126
125
  senderId: candidate,
127
126
  },
128
127
  });
129
128
  try {
130
- const title = encodeURIComponent("Lemonade Access Request");
131
- const body = encodeURIComponent(`${displayName} is requesting access to your Lemon via WhatsApp.`);
129
+ const title = encodeURIComponent("WhatsApp Message");
130
+ const body = encodeURIComponent(`${displayName} sent a message. Add them in Settings to let Lemon respond.`);
132
131
  const channel = encodeURIComponent("whatsapp");
133
132
  const encodedName = encodeURIComponent(displayName);
134
133
  const { exec } = await import("node:child_process");
@@ -137,14 +136,8 @@ export async function checkInboundAccessControl(params) {
137
136
  catch {
138
137
  // Notification is best-effort
139
138
  }
140
- try {
141
- await params.sock.sendMessage(params.remoteJid, {
142
- text: buildPairingReply({ channel: "whatsapp" }),
143
- });
144
- }
145
- catch (err) {
146
- logVerbose(`whatsapp access reply failed for ${candidate}: ${String(err)}`);
147
- }
139
+ // Do NOT reply to the sender on WhatsApp -- this is a personal
140
+ // phone number and the sender may have no idea about Lemonade.
148
141
  }
149
142
  }
150
143
  }
@@ -329,6 +329,14 @@ export const VoiceCallConfigSchema = z
329
329
  /** Maximum call duration in seconds */
330
330
  maxDurationSeconds: z.number().int().positive().default(300),
331
331
 
332
+ /**
333
+ * Maximum age of a call in seconds before it is automatically reaped.
334
+ * Catches calls stuck in unexpected states (e.g., notify-mode calls that
335
+ * never receive a terminal webhook). Set to 0 to disable.
336
+ * Default: 0 (disabled). Recommended: 120-300 for production.
337
+ */
338
+ staleCallReaperSeconds: z.number().int().nonnegative().default(0),
339
+
332
340
  /** Silence timeout for end-of-speech detection (ms) */
333
341
  silenceTimeoutMs: z.number().int().positive().default(800),
334
342
 
@@ -22,6 +22,7 @@ export class VoiceCallWebhookServer {
22
22
  private manager: CallManager;
23
23
  private provider: VoiceCallProvider;
24
24
  private coreConfig: CoreConfig | null;
25
+ private staleCallReaperInterval: ReturnType<typeof setInterval> | null = null;
25
26
 
26
27
  /** Media stream handler for bidirectional audio (when streaming enabled) */
27
28
  private mediaStreamHandler: MediaStreamHandler | null = null;
@@ -198,14 +199,50 @@ export class VoiceCallWebhookServer {
198
199
  );
199
200
  }
200
201
  resolve(url);
202
+
203
+ this.startStaleCallReaper();
201
204
  });
202
205
  });
203
206
  }
204
207
 
208
+ /**
209
+ * Start a periodic reaper that ends calls older than the configured threshold.
210
+ * Catches calls stuck in unexpected states (e.g., notify-mode calls that never
211
+ * receive a terminal webhook from the provider).
212
+ */
213
+ private startStaleCallReaper(): void {
214
+ const maxAgeSeconds = this.config.staleCallReaperSeconds;
215
+ if (!maxAgeSeconds || maxAgeSeconds <= 0) {
216
+ return;
217
+ }
218
+
219
+ const CHECK_INTERVAL_MS = 30_000;
220
+ const maxAgeMs = maxAgeSeconds * 1000;
221
+
222
+ this.staleCallReaperInterval = setInterval(() => {
223
+ const now = Date.now();
224
+ for (const call of this.manager.getActiveCalls()) {
225
+ const age = now - call.startedAt;
226
+ if (age > maxAgeMs) {
227
+ console.log(
228
+ `[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`,
229
+ );
230
+ void this.manager.endCall(call.callId).catch((err) => {
231
+ console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
232
+ });
233
+ }
234
+ }
235
+ }, CHECK_INTERVAL_MS);
236
+ }
237
+
205
238
  /**
206
239
  * Stop the webhook server.
207
240
  */
208
241
  async stop(): Promise<void> {
242
+ if (this.staleCallReaperInterval) {
243
+ clearInterval(this.staleCallReaperInterval);
244
+ this.staleCallReaperInterval = null;
245
+ }
209
246
  return new Promise((resolve) => {
210
247
  if (this.server) {
211
248
  this.server.close(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heylemon/lemonade",
3
- "version": "0.0.9",
3
+ "version": "0.1.1",
4
4
  "description": "AI gateway CLI for Lemon - local AI assistant with integrations",
5
5
  "publishConfig": {
6
6
  "access": "restricted"