@heylemon/lemonade 0.0.1 → 0.0.3

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.
@@ -349,6 +349,10 @@ export function buildAgentSystemPrompt(params) {
349
349
  "- If files must be written, use `mktemp -d` and clean up after (`rm -rf $TMPDIR`).",
350
350
  "- Skill-provided scripts (docx, xlsx, etc.) are fine to use as-is.",
351
351
  "",
352
+ "## Cron / Reminders",
353
+ "ALWAYS use the `cron` tool (action: add/update/remove/list/run/status) for managing cron jobs and reminders.",
354
+ "NEVER use `exec` or shell commands to read/write cron files (e.g. ~/.lemonade/cron/jobs.json) directly — the gateway cron service will not detect the change.",
355
+ "",
352
356
  "## Lemonade CLI Quick Reference",
353
357
  "Lemonade is controlled via subcommands. Do not invent commands.",
354
358
  "To manage the Gateway daemon service (start/stop/restart):",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.1",
3
- "commit": "b1e752b7e12e9b825bce88e0633a044f282ddbd8",
4
- "builtAt": "2026-02-20T04:17:06.425Z"
2
+ "version": "0.0.3",
3
+ "commit": "8e50eaf2ca7f01fbb0397deb3de3cb00f2708c6a",
4
+ "builtAt": "2026-02-20T05:09:41.173Z"
5
5
  }
@@ -1 +1 @@
1
- 9ea9d25692307b9abbc49da05300ef11092a5af306ffa2d75a550a12063181be
1
+ 5ec7ab3a52d725f743100ac38ce0d8c9672f04988280eeecd179252ba58b11dd
@@ -2,7 +2,7 @@ import { listPairingChannels, notifyPairingApproved } from "../channels/plugins/
2
2
  import { normalizeChannelId } from "../channels/plugins/index.js";
3
3
  import { loadConfig } from "../config/config.js";
4
4
  import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
5
- import { approveChannelPairingCode, listChannelPairingRequests, } from "../pairing/pairing-store.js";
5
+ import { approveChannelPairingCode, listChannelPairingRequests, rejectChannelPairingCode, } from "../pairing/pairing-store.js";
6
6
  import { defaultRuntime } from "../runtime.js";
7
7
  import { formatDocsLink } from "../terminal/links.js";
8
8
  import { renderTable } from "../terminal/table.js";
@@ -116,4 +116,30 @@ export function registerPairingCli(program) {
116
116
  defaultRuntime.log(theme.warn(`Failed to notify requester: ${String(err)}`));
117
117
  });
118
118
  });
119
+ pairing
120
+ .command("reject")
121
+ .alias("decline")
122
+ .description("Reject a pairing request (removes it without granting access)")
123
+ .option("--channel <channel>", `Channel (${channels.join(", ")})`)
124
+ .argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
125
+ .argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
126
+ .action(async (codeOrChannel, code, opts) => {
127
+ const channelRaw = opts.channel ?? codeOrChannel;
128
+ const resolvedCode = opts.channel ? codeOrChannel : code;
129
+ if (!opts.channel && !code) {
130
+ throw new Error(`Usage: ${formatCliCommand("lemonade pairing reject <channel> <code>")} (or: ${formatCliCommand("lemonade pairing reject --channel <channel> <code>")})`);
131
+ }
132
+ if (opts.channel && code != null) {
133
+ throw new Error(`Too many arguments. Use: ${formatCliCommand("lemonade pairing reject --channel <channel> <code>")}`);
134
+ }
135
+ const channel = parseChannel(channelRaw, channels);
136
+ const rejected = await rejectChannelPairingCode({
137
+ channel,
138
+ code: String(resolvedCode),
139
+ });
140
+ if (!rejected) {
141
+ throw new Error(`No pending pairing request found for code: ${String(resolvedCode)}`);
142
+ }
143
+ defaultRuntime.log(`${theme.success("Rejected")} ${theme.muted(channel)} request from ${theme.command(rejected.id)}.`);
144
+ });
119
145
  }
@@ -1,6 +1,6 @@
1
1
  import { applyJobPatch, computeJobNextRunAtMs, createJob, findJobOrThrow, isJobDue, nextWakeAtMs, recomputeNextRuns, } from "./jobs.js";
2
2
  import { locked } from "./locked.js";
3
- import { ensureLoaded, persist, warnIfDisabled } from "./store.js";
3
+ import { ensureLoaded, forceReload, persist, startFileWatcher, stopFileWatcher, warnIfDisabled, } from "./store.js";
4
4
  import { armTimer, emit, executeJob, stopTimer, wake } from "./timer.js";
5
5
  export async function start(state) {
6
6
  await locked(state, async () => {
@@ -12,6 +12,18 @@ export async function start(state) {
12
12
  recomputeNextRuns(state);
13
13
  await persist(state);
14
14
  armTimer(state);
15
+ startFileWatcher(state, () => {
16
+ state.deps.log.info({}, "cron: external file change detected, reloading");
17
+ void locked(state, async () => {
18
+ await forceReload(state);
19
+ recomputeNextRuns(state);
20
+ await persist(state);
21
+ armTimer(state);
22
+ state.deps.log.info({ jobs: state.store?.jobs.length ?? 0, nextWakeAtMs: nextWakeAtMs(state) ?? null }, "cron: reloaded from disk");
23
+ }).catch((err) => {
24
+ state.deps.log.error({ err: String(err) }, "cron: reload after file change failed");
25
+ });
26
+ });
15
27
  state.deps.log.info({
16
28
  enabled: true,
17
29
  jobs: state.store?.jobs.length ?? 0,
@@ -21,6 +33,7 @@ export async function start(state) {
21
33
  }
22
34
  export function stop(state) {
23
35
  stopTimer(state);
36
+ stopFileWatcher(state);
24
37
  }
25
38
  export async function status(state) {
26
39
  return await locked(state, async () => {
@@ -6,5 +6,7 @@ export function createCronServiceState(deps) {
6
6
  running: false,
7
7
  op: Promise.resolve(),
8
8
  warnedDisabled: false,
9
+ fileWatcher: null,
10
+ lastPersistTs: 0,
9
11
  };
10
12
  }
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { migrateLegacyCronPayload } from "../payload-migration.js";
2
4
  import { loadCronStore, saveCronStore } from "../store.js";
3
5
  import { inferLegacyName, normalizeOptionalText } from "./normalize.js";
@@ -53,5 +55,44 @@ export function warnIfDisabled(state, action) {
53
55
  export async function persist(state) {
54
56
  if (!state.store)
55
57
  return;
58
+ state.lastPersistTs = Date.now();
56
59
  await saveCronStore(state.deps.storePath, state.store);
57
60
  }
61
+ /**
62
+ * Force-reload the cron store from disk, discarding the in-memory cache.
63
+ * Used when the file is modified externally (e.g. by `exec`).
64
+ */
65
+ export async function forceReload(state) {
66
+ storeCache.delete(state.deps.storePath);
67
+ state.store = null;
68
+ await ensureLoaded(state);
69
+ }
70
+ const EXTERNAL_CHANGE_GRACE_MS = 2000;
71
+ export function startFileWatcher(state, onExternalChange) {
72
+ stopFileWatcher(state);
73
+ const dir = path.dirname(state.deps.storePath);
74
+ const basename = path.basename(state.deps.storePath);
75
+ try {
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ const watcher = fs.watch(dir, (eventType, filename) => {
78
+ if (filename !== basename)
79
+ return;
80
+ if (Date.now() - state.lastPersistTs < EXTERNAL_CHANGE_GRACE_MS)
81
+ return;
82
+ onExternalChange();
83
+ });
84
+ watcher.on("error", () => {
85
+ // Non-fatal; the watcher may fail on some file systems
86
+ });
87
+ state.fileWatcher = watcher;
88
+ }
89
+ catch {
90
+ // Watching is best-effort
91
+ }
92
+ }
93
+ export function stopFileWatcher(state) {
94
+ if (state.fileWatcher) {
95
+ state.fileWatcher.close();
96
+ state.fileWatcher = null;
97
+ }
98
+ }
@@ -1,10 +1,9 @@
1
1
  export function buildPairingReply(params) {
2
- const { channel, idLine, code } = params;
3
2
  return [
4
- "Hey! I'm not able to chat with you yet.",
3
+ "Hey! I don't have permission to chat with you yet.",
5
4
  "",
6
- "Your request has been sent to the admin. Once they accept it, I'll be ready to help you.",
5
+ "The administrator has been notified of your request. They can grant you access from the Lemon app.",
7
6
  "",
8
- "Please ask the admin to check their Lemon app to approve your access.",
7
+ "If you know the administrator, please reach out to them directly to speed things up.",
9
8
  ].join("\n");
10
9
  }
@@ -355,6 +355,34 @@ export async function upsertChannelPairingRequest(params) {
355
355
  return { code, created: true };
356
356
  });
357
357
  }
358
+ export async function rejectChannelPairingCode(params) {
359
+ const env = params.env ?? process.env;
360
+ const code = params.code.trim().toUpperCase();
361
+ if (!code)
362
+ return null;
363
+ const filePath = resolvePairingPath(params.channel, env);
364
+ return await withFileLock(filePath, { version: 1, requests: [] }, async () => {
365
+ const { value } = await readJsonFile(filePath, {
366
+ version: 1,
367
+ requests: [],
368
+ });
369
+ const reqs = Array.isArray(value.requests) ? value.requests : [];
370
+ const nowMs = Date.now();
371
+ const { requests: pruned } = pruneExpiredRequests(reqs, nowMs);
372
+ const idx = pruned.findIndex((r) => String(r.code ?? "").toUpperCase() === code);
373
+ if (idx < 0)
374
+ return null;
375
+ const entry = pruned[idx];
376
+ if (!entry)
377
+ return null;
378
+ pruned.splice(idx, 1);
379
+ await writeJsonFile(filePath, {
380
+ version: 1,
381
+ requests: pruned,
382
+ });
383
+ return { id: entry.id, entry };
384
+ });
385
+ }
358
386
  export async function approveChannelPairingCode(params) {
359
387
  const env = params.env ?? process.env;
360
388
  const code = params.code.trim().toUpperCase();
@@ -111,57 +111,49 @@ export async function prepareSlackMessage(params) {
111
111
  isGuestSender = true;
112
112
  logVerbose(`slack: guest sender ${directUserId} allowed (responses only, no tools)`);
113
113
  }
114
- else if (ctx.dmPolicy === "pairing") {
114
+ else if (ctx.dmPolicy === "pairing" || ctx.dmPolicy === "allowlist") {
115
115
  const sender = await ctx.resolveUserName(directUserId);
116
116
  const senderName = sender?.name ?? undefined;
117
- const { code, created } = await upsertChannelPairingRequest({
117
+ const { created } = await upsertChannelPairingRequest({
118
118
  channel: "slack",
119
119
  id: directUserId,
120
120
  meta: { name: senderName },
121
121
  });
122
122
  if (created) {
123
- logVerbose(`slack pairing request sender=${directUserId} name=${senderName ?? "unknown"} (${allowMatchMeta})`);
123
+ logVerbose(`slack access request sender=${directUserId} name=${senderName ?? "unknown"} (${allowMatchMeta})`);
124
124
  const displayName = senderName ?? directUserId;
125
- // Notify frontend via SSE (reliable — works regardless of gateway context)
126
125
  emitAgentEvent({
127
126
  runId: `pairing-slack-${directUserId}-${Date.now()}`,
128
127
  stream: "notification",
129
128
  data: {
130
129
  type: "pairing",
131
130
  channel: "slack",
132
- title: "Access Request",
133
- body: `${displayName} wants to message you on Slack.`,
134
- code,
131
+ title: "Lemonade Access Request",
132
+ body: `${displayName} is requesting access to your Lemon via Slack.`,
135
133
  name: displayName,
136
134
  senderId: directUserId,
137
135
  },
138
136
  });
139
- // Also notify via URL scheme (fallback for when SSE isn't connected)
140
137
  try {
141
- const title = encodeURIComponent("Access Request");
142
- const body = encodeURIComponent(`${displayName} wants to message you on Slack.`);
138
+ const title = encodeURIComponent("Lemonade Access Request");
139
+ const body = encodeURIComponent(`${displayName} is requesting access to your Lemon via Slack.`);
143
140
  const channel = encodeURIComponent("slack");
144
- const encodedCode = encodeURIComponent(code);
145
141
  const encodedName = encodeURIComponent(displayName);
146
142
  const { exec } = await import("node:child_process");
147
- exec(`open "lemon://notify?title=${title}&body=${body}&type=pairing&channel=${channel}&code=${encodedCode}&name=${encodedName}"`);
143
+ exec(`open "lemon://notify?title=${title}&body=${body}&type=pairing&channel=${channel}&name=${encodedName}"`);
148
144
  }
149
145
  catch {
150
146
  // Notification is best-effort
151
147
  }
152
148
  try {
153
- await sendMessageSlack(message.channel, buildPairingReply({
154
- channel: "slack",
155
- idLine: `Your Slack user id: ${directUserId}`,
156
- code,
157
- }), {
149
+ await sendMessageSlack(message.channel, buildPairingReply({ channel: "slack" }), {
158
150
  token: ctx.botToken,
159
151
  client: ctx.app.client,
160
152
  accountId: account.accountId,
161
153
  });
162
154
  }
163
155
  catch (err) {
164
- logVerbose(`slack pairing reply failed for ${message.user}: ${String(err)}`);
156
+ logVerbose(`slack access reply failed for ${message.user}: ${String(err)}`);
165
157
  }
166
158
  }
167
159
  return null;
@@ -100,58 +100,50 @@ export async function checkInboundAccessControl(params) {
100
100
  const allowed = dmHasWildcard ||
101
101
  (normalizedAllowFrom.length > 0 && normalizedAllowFrom.includes(candidate));
102
102
  if (!allowed) {
103
- if (dmPolicy === "pairing") {
103
+ if (dmPolicy === "pairing" || dmPolicy === "allowlist") {
104
104
  if (suppressPairingReply) {
105
- logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`);
105
+ logVerbose(`Skipping access reply for historical DM from ${candidate}.`);
106
106
  }
107
107
  else {
108
- const { code, created } = await upsertChannelPairingRequest({
108
+ const { created } = await upsertChannelPairingRequest({
109
109
  channel: "whatsapp",
110
110
  id: candidate,
111
111
  meta: { name: (params.pushName ?? "").trim() || undefined },
112
112
  });
113
113
  if (created) {
114
114
  const pushName = (params.pushName ?? "").trim() || "unknown";
115
- logVerbose(`whatsapp pairing request sender=${candidate} name=${pushName}`);
115
+ logVerbose(`whatsapp access request sender=${candidate} name=${pushName}`);
116
116
  const displayName = pushName !== "unknown" ? pushName : candidate;
117
- // Notify frontend via SSE (reliable — works regardless of gateway context)
118
117
  emitAgentEvent({
119
118
  runId: `pairing-whatsapp-${candidate}-${Date.now()}`,
120
119
  stream: "notification",
121
120
  data: {
122
121
  type: "pairing",
123
122
  channel: "whatsapp",
124
- title: "Access Request",
125
- body: `${displayName} wants to message you on WhatsApp.`,
126
- code,
123
+ title: "Lemonade Access Request",
124
+ body: `${displayName} is requesting access to your Lemon via WhatsApp.`,
127
125
  name: displayName,
128
126
  senderId: candidate,
129
127
  },
130
128
  });
131
- // Also notify via URL scheme (fallback for when SSE isn't connected)
132
129
  try {
133
- const title = encodeURIComponent("Access Request");
134
- const body = encodeURIComponent(`${displayName} wants to message you on WhatsApp.`);
130
+ const title = encodeURIComponent("Lemonade Access Request");
131
+ const body = encodeURIComponent(`${displayName} is requesting access to your Lemon via WhatsApp.`);
135
132
  const channel = encodeURIComponent("whatsapp");
136
- const encodedCode = encodeURIComponent(code);
137
133
  const encodedName = encodeURIComponent(displayName);
138
134
  const { exec } = await import("node:child_process");
139
- exec(`open "lemon://notify?title=${title}&body=${body}&type=pairing&channel=${channel}&code=${encodedCode}&name=${encodedName}"`);
135
+ exec(`open "lemon://notify?title=${title}&body=${body}&type=pairing&channel=${channel}&name=${encodedName}"`);
140
136
  }
141
137
  catch {
142
138
  // Notification is best-effort
143
139
  }
144
140
  try {
145
141
  await params.sock.sendMessage(params.remoteJid, {
146
- text: buildPairingReply({
147
- channel: "whatsapp",
148
- idLine: `Your WhatsApp phone number: ${candidate}`,
149
- code,
150
- }),
142
+ text: buildPairingReply({ channel: "whatsapp" }),
151
143
  });
152
144
  }
153
145
  catch (err) {
154
- logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`);
146
+ logVerbose(`whatsapp access reply failed for ${candidate}: ${String(err)}`);
155
147
  }
156
148
  }
157
149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heylemon/lemonade",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "AI gateway CLI for Lemon - local AI assistant with integrations",
5
5
  "publishConfig": {
6
6
  "access": "restricted"