@2en/clawly-plugins 1.30.0-beta.4 → 1.30.0-beta.6

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.
@@ -179,9 +179,23 @@
179
179
  },
180
180
  },
181
181
 
182
+ // gateway.reload — use "hot" mode to prevent spurious SIGUSR1 restarts.
183
+ // config-setup writes restart-requiring keys (commands.*, gateway.*, env.*)
184
+ // after the gateway has already captured its startup config snapshot.
185
+ // In "hybrid" (default) mode, any subsequent config file write triggers the
186
+ // file watcher to diff against the stale snapshot, discover the config-setup
187
+ // changes as "new", and issue SIGUSR1. "hot" mode makes the watcher ignore
188
+ // restart-requiring diffs — only hot-reloadable changes (heartbeat, cron,
189
+ // hooks, browser) are applied. Direct SIGUSR1 from explicit restart commands
190
+ // and plugin auto-update is unaffected. If you manually edit a restart-
191
+ // requiring field in openclaw.json, run `openclaw restart` to apply it.
192
+ //
182
193
  // gateway.nodes — allowlist dangerous node commands so the AI agent can
183
194
  // invoke them on connected Clawly nodes (browser, reminders, calendar, device).
184
195
  gateway: {
196
+ reload: {
197
+ mode: "hot",
198
+ },
185
199
  nodes: {
186
200
  allowCommands: [
187
201
  // Browser commands (Mac nodes)
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Model switch RPC: writes agents.defaults.model.primary to openclaw.json
3
+ * without triggering a gateway restart.
4
+ *
5
+ * OpenClaw reads agents.defaults.model.primary dynamically on each chat
6
+ * request (loadConfig() has a ~200ms cache). A disk write takes effect
7
+ * within that window — no SIGUSR1 needed.
8
+ *
9
+ * Uses the runtime config APIs (loadConfig + writeConfigFile) for atomic
10
+ * writes, env var preservation, and config validation.
11
+ *
12
+ * Methods:
13
+ * - clawly.config.setModel({ model }) → { changed, model }
14
+ */
15
+
16
+ import type {PluginApi} from '../types'
17
+ import type {OpenClawConfig} from '../types/openclaw'
18
+
19
+ export function registerConfigModel(api: PluginApi) {
20
+ api.registerGatewayMethod('clawly.config.setModel', async ({params, respond}) => {
21
+ const model = typeof params.model === 'string' ? params.model : ''
22
+ if (!model) {
23
+ respond(true, {changed: false, model: '', error: 'Missing model param'})
24
+ return
25
+ }
26
+
27
+ let config: OpenClawConfig
28
+ try {
29
+ config = {...(api.runtime.config.loadConfig() as OpenClawConfig)}
30
+ } catch (err) {
31
+ const msg = err instanceof Error ? err.message : String(err)
32
+ respond(true, {changed: false, model, error: `Load failed: ${msg}`})
33
+ return
34
+ }
35
+
36
+ const current = (config.agents as Record<string, unknown> | undefined)?.defaults as
37
+ | Record<string, unknown>
38
+ | undefined
39
+ const currentModel = (current?.model as Record<string, unknown> | undefined)?.primary
40
+
41
+ if (currentModel === model) {
42
+ respond(true, {changed: false, model})
43
+ return
44
+ }
45
+
46
+ // Shallow-copy nested objects to avoid polluting the loadConfig() cache
47
+ // if writeConfigFile fails below.
48
+ const agents = {...((config.agents ?? {}) as Record<string, unknown>)}
49
+ const defaults = {...((agents.defaults ?? {}) as Record<string, unknown>)}
50
+ const modelObj = {...((defaults.model ?? {}) as Record<string, unknown>)}
51
+ modelObj.primary = model
52
+ defaults.model = modelObj
53
+ agents.defaults = defaults
54
+ config.agents = agents
55
+
56
+ try {
57
+ await api.runtime.config.writeConfigFile(config)
58
+ api.logger.info(`config-model: set model.primary to ${model}`)
59
+ respond(true, {changed: true, model})
60
+ } catch (err) {
61
+ const msg = err instanceof Error ? err.message : String(err)
62
+ api.logger.error(`config-model: write failed — ${msg}`)
63
+ respond(true, {changed: false, model, error: `Write failed: ${msg}`})
64
+ }
65
+ })
66
+
67
+ api.logger.info('config-model: registered clawly.config.setModel')
68
+ }
@@ -2,14 +2,15 @@
2
2
  * Timezone sync RPC: writes agents.defaults.userTimezone to openclaw.json
3
3
  * without triggering a gateway restart.
4
4
  *
5
+ * Uses the runtime config APIs (loadConfig + writeConfigFile) for atomic
6
+ * writes, env var preservation, and config validation.
7
+ *
5
8
  * Methods:
6
9
  * - clawly.config.setTimezone({ timezone }) → { changed, timezone }
7
10
  */
8
11
 
9
- import path from 'node:path'
10
-
11
12
  import type {PluginApi} from '../types'
12
- import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
13
+ import type {OpenClawConfig} from '../types/openclaw'
13
14
 
14
15
  export function registerConfigTimezone(api: PluginApi) {
15
16
  api.registerGatewayMethod('clawly.config.setTimezone', async ({params, respond}) => {
@@ -19,30 +20,34 @@ export function registerConfigTimezone(api: PluginApi) {
19
20
  return
20
21
  }
21
22
 
22
- const stateDir = api.runtime.state.resolveStateDir()
23
- if (!stateDir) {
24
- respond(true, {changed: false, timezone, error: 'Cannot resolve state dir'})
23
+ let config: OpenClawConfig
24
+ try {
25
+ config = {...(api.runtime.config.loadConfig() as OpenClawConfig)}
26
+ } catch (err) {
27
+ const msg = err instanceof Error ? err.message : String(err)
28
+ respond(true, {changed: false, timezone, error: `Load failed: ${msg}`})
25
29
  return
26
30
  }
27
31
 
28
- const configPath = path.join(stateDir, 'openclaw.json')
29
- const config = readOpenclawConfig(configPath)
30
-
31
- const agents = (config.agents ?? {}) as Record<string, unknown>
32
- const defaults = (agents.defaults ?? {}) as Record<string, unknown>
33
- const current = defaults.userTimezone
32
+ const currentDefaults = (config.agents as Record<string, unknown> | undefined)?.defaults as
33
+ | Record<string, unknown>
34
+ | undefined
34
35
 
35
- if (current === timezone) {
36
+ if (currentDefaults?.userTimezone === timezone) {
36
37
  respond(true, {changed: false, timezone})
37
38
  return
38
39
  }
39
40
 
41
+ // Shallow-copy nested objects to avoid polluting the loadConfig() cache
42
+ // if writeConfigFile fails below.
43
+ const agents = {...((config.agents ?? {}) as Record<string, unknown>)}
44
+ const defaults = {...((agents.defaults ?? {}) as Record<string, unknown>)}
40
45
  defaults.userTimezone = timezone
41
46
  agents.defaults = defaults
42
47
  config.agents = agents
43
48
 
44
49
  try {
45
- writeOpenclawConfig(configPath, config)
50
+ await api.runtime.config.writeConfigFile(config)
46
51
  api.logger.info(`config-timezone: set userTimezone to ${timezone}`)
47
52
  respond(true, {changed: true, timezone})
48
53
  } catch (err) {
@@ -263,6 +263,22 @@ describe('cron-delivery', () => {
263
263
  })
264
264
  })
265
265
 
266
+ test('skips HEARTBEAT OK with space (bare)', async () => {
267
+ const {api, logs, handlers} = createMockApi()
268
+ registerCronDelivery(api)
269
+
270
+ await handlers.get('agent_end')!(
271
+ {messages: makeMessages('HEARTBEAT OK')},
272
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
273
+ )
274
+
275
+ expect(injectCalls).toHaveLength(0)
276
+ expect(logs).toContainEqual({
277
+ level: 'info',
278
+ msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
279
+ })
280
+ })
281
+
266
282
  test('skips empty text', async () => {
267
283
  const {api, logs, handlers} = createMockApi()
268
284
  registerCronDelivery(api)
package/gateway/index.ts CHANGED
@@ -4,6 +4,7 @@ import {registerCalendarNative} from './calendar-native'
4
4
  import {registerAnalytics} from './analytics'
5
5
  import {registerAudit} from './audit'
6
6
  import {registerClawhub2gateway} from './clawhub2gateway'
7
+ import {registerConfigModel} from './config-model'
7
8
  import {registerConfigRepair} from './config-repair'
8
9
  import {registerConfigTimezone} from './config-timezone'
9
10
  import {registerCronDelivery} from './cron-delivery'
@@ -57,6 +58,7 @@ export function registerGateway(api: PluginApi) {
57
58
  registerCronTelemetry(api)
58
59
  registerMessageLog(api)
59
60
  registerAnalytics(api)
61
+ registerConfigModel(api)
60
62
  registerConfigRepair(api)
61
63
  registerConfigTimezone(api)
62
64
  registerSessionSanitize(api)
@@ -446,6 +446,22 @@ describe('shouldSkipPushForMessage', () => {
446
446
  expect(shouldSkipPushForMessage('The token HEARTBEAT_OK is used for health checks.')).toBeNull()
447
447
  })
448
448
 
449
+ test('skips heartbeat ack with space variant (HEARTBEAT OK)', () => {
450
+ expect(shouldSkipPushForMessage('HEARTBEAT OK')).toBe('heartbeat ack')
451
+ expect(shouldSkipPushForMessage('HEARTBEAT OK.')).toBe('heartbeat ack')
452
+ expect(shouldSkipPushForMessage('HEARTBEAT OK\n')).toBe('heartbeat ack')
453
+ })
454
+
455
+ test('skips short content ending with HEARTBEAT OK (space variant)', () => {
456
+ expect(shouldSkipPushForMessage('All good. HEARTBEAT OK')).toBe('heartbeat ack')
457
+ expect(shouldSkipPushForMessage('Nothing to report. HEARTBEAT OK。')).toBe('heartbeat ack')
458
+ })
459
+
460
+ test('skips HEARTBEAT OK at start with short status note (space variant)', () => {
461
+ expect(shouldSkipPushForMessage('HEARTBEAT OK — all systems nominal.')).toBe('heartbeat ack')
462
+ expect(shouldSkipPushForMessage('HEARTBEAT OK. Nothing to report.')).toBe('heartbeat ack')
463
+ })
464
+
449
465
  test('skips system prompt leak', () => {
450
466
  expect(
451
467
  shouldSkipPushForMessage('Here is some Conversation info (untrusted metadata) text'),
@@ -584,6 +600,33 @@ describe('offline-push with filtered messages', () => {
584
600
  expect(lastPushOpts?.body?.endsWith('…')).toBe(true)
585
601
  })
586
602
 
603
+ test('sends push for long content and strips HEARTBEAT OK (space variant) from body', async () => {
604
+ const {api, logs, handlers} = createMockApi()
605
+ registerOfflinePush(api)
606
+
607
+ const longContent = 'A'.repeat(301)
608
+ await handlers.get('agent_end')!(
609
+ {
610
+ messages: [
611
+ {
612
+ role: 'assistant',
613
+ content: `${longContent}\n\nHEARTBEAT OK`,
614
+ },
615
+ ],
616
+ },
617
+ {sessionKey: 'agent:clawly:main'},
618
+ )
619
+
620
+ expect(logs).toContainEqual({
621
+ level: 'info',
622
+ msg: expect.stringContaining('notified (session=agent:clawly:main)'),
623
+ })
624
+ // HEARTBEAT OK should be stripped from body, content truncated to 140
625
+ expect(lastPushOpts?.body?.length).toBe(141)
626
+ expect(lastPushOpts?.body?.endsWith('…')).toBe(true)
627
+ expect(lastPushOpts?.body).not.toContain('HEARTBEAT')
628
+ })
629
+
587
630
  test('sends push for normal message text', async () => {
588
631
  const {api, logs, handlers} = createMockApi()
589
632
  registerOfflinePush(api)
@@ -160,12 +160,12 @@ export function shouldSkipPushForMessage(text: string): string | null {
160
160
  // Heartbeat acknowledgment — strip HEARTBEAT_OK from both edges (mirrors
161
161
  // OpenClaw's stripTokenAtEdges). Skip if remaining text ≤ ackMaxChars (300).
162
162
  const HEARTBEAT_ACK_MAX_CHARS = 300
163
- const hasAtEnd = /HEARTBEAT_OK[\p{P}\s]*$/u.test(text)
164
- const hasAtStart = /^[\p{P}\s]*HEARTBEAT_OK/u.test(text)
163
+ const hasAtEnd = /HEARTBEAT[_ ]OK[\p{P}\s]*$/u.test(text)
164
+ const hasAtStart = /^[\p{P}\s]*HEARTBEAT[_ ]OK/u.test(text)
165
165
  if (hasAtEnd || hasAtStart) {
166
166
  let stripped = text
167
- if (hasAtEnd) stripped = stripped.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '')
168
- if (hasAtStart) stripped = stripped.replace(/^[\p{P}\s]*HEARTBEAT_OK[\p{P}\s]*/u, '')
167
+ if (hasAtEnd) stripped = stripped.replace(/HEARTBEAT[_ ]OK[\p{P}\s]*$/u, '')
168
+ if (hasAtStart) stripped = stripped.replace(/^[\p{P}\s]*HEARTBEAT[_ ]OK[\p{P}\s]*/u, '')
169
169
  stripped = stripped.trim()
170
170
  if (stripped.length <= HEARTBEAT_ACK_MAX_CHARS) return 'heartbeat ack'
171
171
  }
@@ -308,12 +308,12 @@ export function registerOfflinePush(api: PluginApi) {
308
308
  const noHeartbeat =
309
309
  (() => {
310
310
  if (!fullText) return null
311
- const atEnd = /HEARTBEAT_OK[\p{P}\s]*$/u.test(fullText)
312
- const atStart = /^[\p{P}\s]*HEARTBEAT_OK/u.test(fullText)
311
+ const atEnd = /HEARTBEAT[_ ]OK[\p{P}\s]*$/u.test(fullText)
312
+ const atStart = /^[\p{P}\s]*HEARTBEAT[_ ]OK/u.test(fullText)
313
313
  if (!atEnd && !atStart) return fullText.trim()
314
314
  let s = fullText
315
- if (atEnd) s = s.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '')
316
- if (atStart) s = s.replace(/^[\p{P}\s]*HEARTBEAT_OK[\p{P}\s]*/u, '')
315
+ if (atEnd) s = s.replace(/HEARTBEAT[_ ]OK[\p{P}\s]*$/u, '')
316
+ if (atStart) s = s.replace(/^[\p{P}\s]*HEARTBEAT[_ ]OK[\p{P}\s]*/u, '')
317
317
  return s.trim()
318
318
  })() ?? null
319
319
  const cleaned = noHeartbeat ? stripPlaceholders(noHeartbeat) : null
@@ -27,14 +27,17 @@ export function isOnlineEntry(entry: PresenceEntry | undefined): boolean {
27
27
  }
28
28
 
29
29
  /**
30
- * Shells out to `openclaw gateway call system-presence` and checks
31
- * whether ANY device has a foreground presence entry.
30
+ * Shells out to `openclaw gateway call system-presence` and returns true
31
+ * if any device has a `foreground` presence entry.
32
+ *
33
+ * False-negatives (beacon lapsed) are handled client-side — the mobile
34
+ * app suppresses `agent_end` notifications while in the foreground.
32
35
  */
33
36
  export async function isClientOnline(): Promise<boolean> {
34
37
  try {
35
38
  const result = await $`openclaw gateway call system-presence --json`
36
- const jsonStr = stripCliLogs(result.stdout)
37
- const entries: PresenceEntry[] = JSON.parse(jsonStr)
39
+ const parsed: unknown = JSON.parse(stripCliLogs(result.stdout))
40
+ const entries: PresenceEntry[] = Array.isArray(parsed) ? parsed : []
38
41
  return entries.some(isOnlineEntry)
39
42
  } catch {
40
43
  return false
package/gateway-fetch.ts CHANGED
@@ -11,11 +11,18 @@ export type HandlerResult = {ok: boolean; data?: unknown; error?: {code: string;
11
11
  export function getGatewayConfig(api: PluginApi): GatewayCfg {
12
12
  const cfg = api.pluginConfig && typeof api.pluginConfig === 'object' ? api.pluginConfig : {}
13
13
  const c = cfg as Record<string, unknown>
14
- const baseUrl =
15
- typeof c.skillGatewayBaseUrl === 'string'
16
- ? (c.skillGatewayBaseUrl as string).replace(/\/$/, '')
17
- : ''
18
- const token = typeof c.skillGatewayToken === 'string' ? (c.skillGatewayToken as string) : ''
14
+
15
+ // URL: prefer clawlyApiBaseUrl, fall back to deprecated skillGatewayBaseUrl
16
+ const rawUrl =
17
+ (typeof c.clawlyApiBaseUrl === 'string' ? (c.clawlyApiBaseUrl as string) : '') ||
18
+ (typeof c.skillGatewayBaseUrl === 'string' ? (c.skillGatewayBaseUrl as string) : '')
19
+ const baseUrl = rawUrl.replace(/\/$/, '')
20
+
21
+ // Token: prefer modelGatewayToken, fall back to deprecated skillGatewayToken
22
+ const token =
23
+ (typeof c.modelGatewayToken === 'string' ? (c.modelGatewayToken as string) : '') ||
24
+ (typeof c.skillGatewayToken === 'string' ? (c.skillGatewayToken as string) : '')
25
+
19
26
  return {baseUrl, token}
20
27
  }
21
28
 
package/index.ts CHANGED
@@ -80,7 +80,7 @@ export default {
80
80
  registerAutoPair(api)
81
81
  registerAutoUpdate(api)
82
82
 
83
- // Email & calendar (optional — requires skillGatewayBaseUrl + skillGatewayToken in config)
83
+ // Email & calendar (optional — requires API base URL + token)
84
84
  const gw = getGatewayConfig(api)
85
85
  if (gw.baseUrl && gw.token) {
86
86
  registerEmail(api, gw)
@@ -23,6 +23,9 @@ export interface NativeCalendar {
23
23
  type: string
24
24
  }
25
25
 
26
+ export type AlarmMethod = 'alarm' | 'alert' | 'email' | 'default' | 'sms'
27
+ export type AlarmInput = {relativeOffset?: number; method?: AlarmMethod}
28
+
26
29
  export interface NativeCalendarEvent {
27
30
  id: string
28
31
  calendarId: string
@@ -35,6 +38,7 @@ export interface NativeCalendarEvent {
35
38
  url: string
36
39
  timeZone: string
37
40
  organizer: string
41
+ alarms?: AlarmInput[]
38
42
  }
39
43
 
40
44
  export interface CalendarCache {
@@ -47,6 +47,7 @@
47
47
  "defaultNoInput": { "type": "boolean" },
48
48
  "defaultTimeoutMs": { "type": "number", "minimum": 1000 },
49
49
  "configPath": { "type": "string" },
50
+ "clawlyApiBaseUrl": { "type": "string" },
50
51
  "skillGatewayBaseUrl": { "type": "string" },
51
52
  "skillGatewayToken": { "type": "string" },
52
53
  "modelGatewayBaseUrl": { "type": "string" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.30.0-beta.4",
3
+ "version": "1.30.0-beta.6",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -158,6 +158,26 @@ export function registerCalendarTools(api: PluginApi) {
158
158
  location: {type: 'string', description: 'Event location'},
159
159
  notes: {type: 'string', description: 'Event description/notes'},
160
160
  timeZone: {type: 'string', description: 'IANA time zone (e.g. America/New_York)'},
161
+ alarms: {
162
+ type: 'array',
163
+ description:
164
+ 'Array of alarm objects to set reminders. Each alarm has a relativeOffset in minutes (negative = before event). Example: [{relativeOffset: -15}] for 15 minutes before. Only alarms with valid relativeOffset (number) or method (string) will be applied. To verify reminders were set, use clawly_calendar_list_events after creation.',
165
+ items: {
166
+ type: 'object',
167
+ properties: {
168
+ relativeOffset: {
169
+ type: 'number',
170
+ description:
171
+ 'Minutes relative to the event start time. Use negative values for reminders before the event (e.g. -15 = 15 min before, -60 = 1 hour before).',
172
+ },
173
+ method: {
174
+ type: 'string',
175
+ enum: ['alarm', 'alert', 'email', 'default', 'sms'],
176
+ description: 'Alarm method (Android only). iOS always uses notification.',
177
+ },
178
+ },
179
+ },
180
+ },
161
181
  },
162
182
  },
163
183
  async execute(_toolCallId, params) {
@@ -211,6 +231,9 @@ export function registerCalendarTools(api: PluginApi) {
211
231
  ...(params.location ? {location: params.location} : {}),
212
232
  ...(params.notes ? {notes: params.notes} : {}),
213
233
  ...(params.timeZone ? {timeZone: params.timeZone} : {}),
234
+ ...(Array.isArray(params.alarms) && params.alarms.length > 0
235
+ ? {alarms: params.alarms}
236
+ : {}),
214
237
  }
215
238
 
216
239
  const resultPromise = waitForActionResult(actionId)
@@ -290,6 +313,26 @@ export function registerCalendarTools(api: PluginApi) {
290
313
  location: {type: 'string', description: 'New event location'},
291
314
  notes: {type: 'string', description: 'New event description/notes'},
292
315
  timeZone: {type: 'string', description: 'IANA time zone (e.g. America/New_York)'},
316
+ alarms: {
317
+ type: 'array',
318
+ description:
319
+ 'Array of alarm objects to set reminders. Each alarm has a relativeOffset in minutes (negative = before event). Example: [{relativeOffset: -15}] for 15 minutes before. Replaces all existing alarms. Pass an empty array [] to clear all existing reminders.',
320
+ items: {
321
+ type: 'object',
322
+ properties: {
323
+ relativeOffset: {
324
+ type: 'number',
325
+ description:
326
+ 'Minutes relative to the event start time. Use negative values for reminders before the event (e.g. -15 = 15 min before, -60 = 1 hour before).',
327
+ },
328
+ method: {
329
+ type: 'string',
330
+ enum: ['alarm', 'alert', 'email', 'default', 'sms'],
331
+ description: 'Alarm method (Android only). iOS always uses notification.',
332
+ },
333
+ },
334
+ },
335
+ },
293
336
  },
294
337
  },
295
338
  async execute(_toolCallId, params) {
@@ -314,6 +357,7 @@ export function registerCalendarTools(api: PluginApi) {
314
357
  ...(params.location !== undefined ? {location: params.location} : {}),
315
358
  ...(params.notes !== undefined ? {notes: params.notes} : {}),
316
359
  ...(params.timeZone !== undefined ? {timeZone: params.timeZone} : {}),
360
+ ...(Array.isArray(params.alarms) ? {alarms: params.alarms} : {}),
317
361
  }
318
362
 
319
363
  const resultPromise = waitForActionResult(actionId)