@2en/clawly-plugins 1.30.0-beta.1 → 1.30.0-beta.10

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,74 @@
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
+ import {backfillDiskConfig} from '../model-gateway-setup'
19
+
20
+ export function registerConfigModel(api: PluginApi) {
21
+ api.registerGatewayMethod('clawly.config.setModel', async ({params, respond}) => {
22
+ const model = typeof params.model === 'string' ? params.model : ''
23
+ if (!model) {
24
+ respond(true, {changed: false, model: '', error: 'Missing model param'})
25
+ return
26
+ }
27
+
28
+ let config: OpenClawConfig
29
+ try {
30
+ config = {...(api.runtime.config.loadConfig() as OpenClawConfig)}
31
+ } catch (err) {
32
+ const msg = err instanceof Error ? err.message : String(err)
33
+ respond(true, {changed: false, model, error: `Load failed: ${msg}`})
34
+ return
35
+ }
36
+
37
+ const current = (config.agents as Record<string, unknown> | undefined)?.defaults as
38
+ | Record<string, unknown>
39
+ | undefined
40
+ const currentModel = (current?.model as Record<string, unknown> | undefined)?.primary
41
+
42
+ if (currentModel === model) {
43
+ respond(true, {changed: false, model})
44
+ return
45
+ }
46
+
47
+ // Shallow-copy nested objects to avoid polluting the loadConfig() cache
48
+ // if writeConfigFile fails below.
49
+ const agents = {...((config.agents ?? {}) as Record<string, unknown>)}
50
+ const defaults = {...((agents.defaults ?? {}) as Record<string, unknown>)}
51
+ const modelObj = {...((defaults.model ?? {}) as Record<string, unknown>)}
52
+ modelObj.primary = model
53
+ defaults.model = modelObj
54
+ agents.defaults = defaults
55
+ config.agents = agents
56
+
57
+ // Backfill fields written by setupConfig (which writes directly to disk)
58
+ // so writeConfigFile's merge-patch doesn't revert them.
59
+ const stateDir = api.runtime.state.resolveStateDir()
60
+ const configToWrite = stateDir ? backfillDiskConfig(stateDir, config) : config
61
+
62
+ try {
63
+ await api.runtime.config.writeConfigFile(configToWrite)
64
+ api.logger.info(`config-model: set model.primary to ${model}`)
65
+ respond(true, {changed: true, model})
66
+ } catch (err) {
67
+ const msg = err instanceof Error ? err.message : String(err)
68
+ api.logger.error(`config-model: write failed — ${msg}`)
69
+ respond(true, {changed: false, model, error: `Write failed: ${msg}`})
70
+ }
71
+ })
72
+
73
+ api.logger.info('config-model: registered clawly.config.setModel')
74
+ }
@@ -2,14 +2,16 @@
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'
14
+ import {backfillDiskConfig} from '../model-gateway-setup'
13
15
 
14
16
  export function registerConfigTimezone(api: PluginApi) {
15
17
  api.registerGatewayMethod('clawly.config.setTimezone', async ({params, respond}) => {
@@ -19,30 +21,39 @@ export function registerConfigTimezone(api: PluginApi) {
19
21
  return
20
22
  }
21
23
 
22
- const stateDir = api.runtime.state.resolveStateDir()
23
- if (!stateDir) {
24
- respond(true, {changed: false, timezone, error: 'Cannot resolve state dir'})
24
+ let config: OpenClawConfig
25
+ try {
26
+ config = {...(api.runtime.config.loadConfig() as OpenClawConfig)}
27
+ } catch (err) {
28
+ const msg = err instanceof Error ? err.message : String(err)
29
+ respond(true, {changed: false, timezone, error: `Load failed: ${msg}`})
25
30
  return
26
31
  }
27
32
 
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
33
+ const currentDefaults = (config.agents as Record<string, unknown> | undefined)?.defaults as
34
+ | Record<string, unknown>
35
+ | undefined
34
36
 
35
- if (current === timezone) {
37
+ if (currentDefaults?.userTimezone === timezone) {
36
38
  respond(true, {changed: false, timezone})
37
39
  return
38
40
  }
39
41
 
42
+ // Shallow-copy nested objects to avoid polluting the loadConfig() cache
43
+ // if writeConfigFile fails below.
44
+ const agents = {...((config.agents ?? {}) as Record<string, unknown>)}
45
+ const defaults = {...((agents.defaults ?? {}) as Record<string, unknown>)}
40
46
  defaults.userTimezone = timezone
41
47
  agents.defaults = defaults
42
48
  config.agents = agents
43
49
 
50
+ // Backfill fields written by setupConfig (which writes directly to disk)
51
+ // so writeConfigFile's merge-patch doesn't revert them.
52
+ const stateDir = api.runtime.state.resolveStateDir()
53
+ const configToWrite = stateDir ? backfillDiskConfig(stateDir, config) : config
54
+
44
55
  try {
45
- writeOpenclawConfig(configPath, config)
56
+ await api.runtime.config.writeConfigFile(configToWrite)
46
57
  api.logger.info(`config-timezone: set userTimezone to ${timezone}`)
47
58
  respond(true, {changed: true, timezone})
48
59
  } 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
@@ -3,8 +3,8 @@ import {registerAgentSend} from './agent'
3
3
  import {registerCalendarNative} from './calendar-native'
4
4
  import {registerAnalytics} from './analytics'
5
5
  import {registerAudit} from './audit'
6
- import {registerNodeDangerousAllowlist} from './node-dangerous-allowlist'
7
6
  import {registerClawhub2gateway} from './clawhub2gateway'
7
+ import {registerConfigModel} from './config-model'
8
8
  import {registerConfigRepair} from './config-repair'
9
9
  import {registerConfigTimezone} from './config-timezone'
10
10
  import {registerCronDelivery} from './cron-delivery'
@@ -58,12 +58,12 @@ export function registerGateway(api: PluginApi) {
58
58
  registerCronTelemetry(api)
59
59
  registerMessageLog(api)
60
60
  registerAnalytics(api)
61
+ registerConfigModel(api)
61
62
  registerConfigRepair(api)
62
63
  registerConfigTimezone(api)
63
64
  registerSessionSanitize(api)
64
65
  registerPairing(api)
65
66
  registerVersion(api)
66
67
  registerAudit(api)
67
- registerNodeDangerousAllowlist(api)
68
68
  registerCalendarNative(api)
69
69
  }
@@ -73,6 +73,11 @@ function createMockApi(): {
73
73
  return {api, logs, handlers}
74
74
  }
75
75
 
76
+ /** Event with a simple assistant message — needed since the skip-on-no-text guard. */
77
+ const eventWithReply = {
78
+ messages: [{role: 'assistant', content: 'Hello from the assistant'}],
79
+ }
80
+
76
81
  // ── Tests ────────────────────────────────────────────────────────
77
82
 
78
83
  beforeEach(() => {
@@ -89,7 +94,7 @@ describe('offline-push', () => {
89
94
  registerOfflinePush(api)
90
95
 
91
96
  const handler = handlers.get('agent_end')!
92
- await handler({}, {sessionKey: 'agent:clawly:main'})
97
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
93
98
 
94
99
  expect(logs).toContainEqual({
95
100
  level: 'info',
@@ -117,8 +122,8 @@ describe('offline-push', () => {
117
122
 
118
123
  const handler = handlers.get('agent_end')!
119
124
 
120
- await handler({}, {sessionKey: 'agent:clawly:main'})
121
- await handler({}, {sessionKey: 'agent:clawly:main'})
125
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
126
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
122
127
 
123
128
  expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(2)
124
129
  })
@@ -129,11 +134,11 @@ describe('offline-push', () => {
129
134
 
130
135
  const handler = handlers.get('agent_end')!
131
136
 
132
- await handler({}, {sessionKey: 'agent:clawly:main'})
137
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
133
138
  expect(lastPushExtras).toEqual({badge: 1})
134
139
  expect(mockBadgeCount).toBe(1)
135
140
 
136
- await handler({}, {sessionKey: 'agent:clawly:main'})
141
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
137
142
  expect(lastPushExtras).toEqual({badge: 2})
138
143
  expect(mockBadgeCount).toBe(2)
139
144
  })
@@ -143,7 +148,7 @@ describe('offline-push', () => {
143
148
  const {api, handlers} = createMockApi()
144
149
  registerOfflinePush(api)
145
150
 
146
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
151
+ await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:main'})
147
152
 
148
153
  expect(lastPushExtras).toEqual({badge: 1})
149
154
  expect(mockBadgeCount).toBe(0) // incremented then decremented
@@ -153,7 +158,7 @@ describe('offline-push', () => {
153
158
  const {api, logs, handlers} = createMockApi()
154
159
  registerOfflinePush(api)
155
160
 
156
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:telegram:12345'})
161
+ await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:telegram:12345'})
157
162
 
158
163
  expect(logs).toContainEqual({
159
164
  level: 'info',
@@ -166,7 +171,9 @@ describe('offline-push', () => {
166
171
  const {api, logs, handlers} = createMockApi()
167
172
  registerOfflinePush(api)
168
173
 
169
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:cron:weather-check:run:abc123'})
174
+ await handlers.get('agent_end')!(eventWithReply, {
175
+ sessionKey: 'agent:clawly:cron:weather-check:run:abc123',
176
+ })
170
177
 
171
178
  expect(logs).toContainEqual({
172
179
  level: 'info',
@@ -180,7 +187,7 @@ describe('offline-push', () => {
180
187
  registerOfflinePush(api)
181
188
 
182
189
  const handler = handlers.get('agent_end')!
183
- await handler({})
190
+ await handler(eventWithReply)
184
191
 
185
192
  expect(logs).toContainEqual({
186
193
  level: 'info',
@@ -192,7 +199,10 @@ describe('offline-push', () => {
192
199
  const {api, handlers} = createMockApi()
193
200
  registerOfflinePush(api)
194
201
 
195
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main', agentId: 'luna'})
202
+ await handlers.get('agent_end')!(eventWithReply, {
203
+ sessionKey: 'agent:clawly:main',
204
+ agentId: 'luna',
205
+ })
196
206
 
197
207
  expect(lastPushOpts?.agentId).toBe('luna')
198
208
  })
@@ -201,8 +211,9 @@ describe('offline-push', () => {
201
211
  const {api, handlers} = createMockApi()
202
212
  registerOfflinePush(api)
203
213
 
204
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
214
+ await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:main'})
205
215
 
216
+ expect(lastPushOpts).not.toBeNull()
206
217
  expect(lastPushOpts?.title).toBeUndefined()
207
218
  })
208
219
 
@@ -223,17 +234,21 @@ describe('offline-push', () => {
223
234
  expect(lastPushOpts?.body).toBe('Hi there! How can I help you today?')
224
235
  })
225
236
 
226
- test('body falls back when no messages', async () => {
227
- const {api, handlers} = createMockApi()
237
+ test('skips push when no messages (no extractable text)', async () => {
238
+ const {api, logs, handlers} = createMockApi()
228
239
  registerOfflinePush(api)
229
240
 
230
241
  await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
231
242
 
232
- expect(lastPushOpts?.body).toBe('Your response is ready')
243
+ expect(lastPushOpts).toBeNull()
244
+ expect(logs).toContainEqual({
245
+ level: 'warn',
246
+ msg: expect.stringContaining('skipped (no extractable assistant text)'),
247
+ })
233
248
  })
234
249
 
235
- test('body falls back when messages has no assistant role', async () => {
236
- const {api, handlers} = createMockApi()
250
+ test('skips push when messages has no assistant role', async () => {
251
+ const {api, logs, handlers} = createMockApi()
237
252
  registerOfflinePush(api)
238
253
 
239
254
  await handlers.get('agent_end')!(
@@ -241,7 +256,11 @@ describe('offline-push', () => {
241
256
  {sessionKey: 'agent:clawly:main'},
242
257
  )
243
258
 
244
- expect(lastPushOpts?.body).toBe('Your response is ready')
259
+ expect(lastPushOpts).toBeNull()
260
+ expect(logs).toContainEqual({
261
+ level: 'warn',
262
+ msg: expect.stringContaining('skipped (no extractable assistant text)'),
263
+ })
245
264
  })
246
265
 
247
266
  test('body strips [[type:value]] placeholders', async () => {
@@ -427,6 +446,22 @@ describe('shouldSkipPushForMessage', () => {
427
446
  expect(shouldSkipPushForMessage('The token HEARTBEAT_OK is used for health checks.')).toBeNull()
428
447
  })
429
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
+
430
465
  test('skips system prompt leak', () => {
431
466
  expect(
432
467
  shouldSkipPushForMessage('Here is some Conversation info (untrusted metadata) text'),
@@ -565,6 +600,33 @@ describe('offline-push with filtered messages', () => {
565
600
  expect(lastPushOpts?.body?.endsWith('…')).toBe(true)
566
601
  })
567
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
+
568
630
  test('sends push for normal message text', async () => {
569
631
  const {api, logs, handlers} = createMockApi()
570
632
  registerOfflinePush(api)
@@ -580,15 +642,15 @@ describe('offline-push with filtered messages', () => {
580
642
  })
581
643
  })
582
644
 
583
- test('sends push when event has no messages (safe default)', async () => {
645
+ test('skips push when event has no messages (no extractable text)', async () => {
584
646
  const {api, logs, handlers} = createMockApi()
585
647
  registerOfflinePush(api)
586
648
 
587
649
  await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
588
650
 
589
651
  expect(logs).toContainEqual({
590
- level: 'info',
591
- msg: expect.stringContaining('notified (session=agent:clawly:main)'),
652
+ level: 'warn',
653
+ msg: expect.stringContaining('skipped (no extractable assistant text)'),
592
654
  })
593
655
  })
594
656
  })
@@ -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
  }
@@ -226,6 +226,7 @@ export function registerOfflinePush(api: PluginApi) {
226
226
 
227
227
  // Extract full assistant text for filtering and preview.
228
228
  const fullText = getLastAssistantText(event.messages)
229
+ const triggerText = getTriggeringUserText(event.messages)
229
230
 
230
231
  // Skip if the message would be filtered by the mobile UI.
231
232
  if (fullText != null) {
@@ -270,6 +271,33 @@ export function registerOfflinePush(api: PluginApi) {
270
271
  return
271
272
  }
272
273
 
274
+ // Defensive: if we can't extract assistant text, sending a generic
275
+ // "Your response is ready" is never useful — the message likely wasn't
276
+ // persisted to the transcript either, so the user opens the app to nothing.
277
+ // Log the messages structure for debugging, then bail.
278
+ if (fullText == null || fullText === '') {
279
+ const msgCount = Array.isArray(event.messages) ? event.messages.length : 'n/a'
280
+ const lastRoles = Array.isArray(event.messages)
281
+ ? event.messages
282
+ .slice(-5)
283
+ .map(
284
+ (m: any) =>
285
+ `${m?.role ?? '?'}(${typeof m?.content === 'string' ? 'str' : Array.isArray(m?.content) ? `parts:${m.content.length}` : typeof m?.content})`,
286
+ )
287
+ .join(', ')
288
+ : 'n/a'
289
+ api.logger.warn(
290
+ `offline-push: skipped (no extractable assistant text) msgCount=${msgCount} lastRoles=[${lastRoles}] triggerText=${triggerText ? `"${triggerText.slice(0, 80)}"` : 'null'}`,
291
+ )
292
+ if (isCron) markCronPushSkipped(sessionKey!, 'no extractable text', false)
293
+ captureEvent('push.skipped', {
294
+ reason: 'no_extractable_text',
295
+ is_cron: isCron,
296
+ ...(sessionKey ? {session_key: sessionKey} : {}),
297
+ })
298
+ return
299
+ }
300
+
273
301
  // Only send push for the main clawly mobile session and cron sessions —
274
302
  // skip channel sessions (telegram, slack, discord, etc.) which have their own delivery.
275
303
  if (sessionKey !== undefined && sessionKey !== 'agent:clawly:main' && !isCron) {
@@ -280,12 +308,12 @@ export function registerOfflinePush(api: PluginApi) {
280
308
  const noHeartbeat =
281
309
  (() => {
282
310
  if (!fullText) return null
283
- const atEnd = /HEARTBEAT_OK[\p{P}\s]*$/u.test(fullText)
284
- 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)
285
313
  if (!atEnd && !atStart) return fullText.trim()
286
314
  let s = fullText
287
- if (atEnd) s = s.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '')
288
- 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, '')
289
317
  return s.trim()
290
318
  })() ?? null
291
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
@@ -17,7 +17,7 @@
17
17
  * Agent tools:
18
18
  * - clawly_is_user_online — check if user's device is connected
19
19
  * - clawly_send_app_push — send a push notification to user's device
20
- * - clawly_send_image — send an image to the user (URL download or local file)
20
+ * - clawly_send_file — send a file to the user (URL or local path under $HOME/tmp)
21
21
  * - clawly_search — web search via Perplexity (replaces denied web_search)
22
22
  * - clawly_send_message — send a message to user via main session agent (supports role: user|assistant)
23
23
  *
@@ -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 {
@@ -105,7 +109,12 @@ export function initCache(): void {
105
109
  }
106
110
 
107
111
  export function getCache(): CalendarCache | null {
108
- if (!cachedData) cachedData = loadCache()
112
+ // Always read from disk to avoid returning stale in-memory state.
113
+ // After gateway restart or ESM/CJS dual-loading, gateway methods and
114
+ // agent tools may hold separate module instances with divergent
115
+ // cachedData. Disk is the shared source of truth. The file is small
116
+ // (<10 KB) and tool calls are infrequent, so the I/O cost is negligible.
117
+ cachedData = loadCache()
109
118
  return cachedData
110
119
  }
111
120
 
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import fs from 'node:fs'
13
+ import path from 'node:path'
13
14
 
14
15
  import type {PluginApi} from './index'
15
16
  import type {OpenClawConfig} from './types/openclaw'
@@ -67,3 +68,48 @@ export function patchModelGateway(config: OpenClawConfig, _api: PluginApi): bool
67
68
 
68
69
  return dirty
69
70
  }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // backfillDiskConfig — merge setupConfig's disk writes into a loadConfig() result
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
77
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
78
+ }
79
+
80
+ /**
81
+ * Deep-merge `source` into `target`, where source wins for any key it has.
82
+ * Arrays and primitives from source overwrite target.
83
+ * Plain objects recurse.
84
+ */
85
+ function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): void {
86
+ for (const key of Object.keys(source)) {
87
+ if (key === '$include') continue // meta-directive, not a resolved config field
88
+ const sv = source[key]
89
+ const tv = target[key]
90
+ if (isPlainObject(sv) && isPlainObject(tv)) {
91
+ deepMerge(tv, sv)
92
+ } else {
93
+ target[key] = sv
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Read the raw disk config (openclaw.json) and deep-merge its fields into a
100
+ * runtime config snapshot (from `loadConfig()`). This ensures that fields
101
+ * written by `setupConfig` (which writes directly to disk) are present in the
102
+ * config object before it's passed to `writeConfigFile`.
103
+ *
104
+ * Without this, `writeConfigFile`'s inner merge-patch sees the stale runtime
105
+ * snapshot (missing setupConfig's changes) and reverts them on disk.
106
+ */
107
+ export function backfillDiskConfig(stateDir: string, config: OpenClawConfig): OpenClawConfig {
108
+ const configPath = path.join(stateDir, 'openclaw.json')
109
+ const diskConfig = readOpenclawConfig(configPath)
110
+ // Start from disk (setupConfig's fields) then overlay config (RPC handler's
111
+ // changes) so the caller's modifications win over stale disk values.
112
+ const merged = {...diskConfig} as Record<string, unknown>
113
+ deepMerge(merged, config as Record<string, unknown>)
114
+ return merged as OpenClawConfig
115
+ }
@@ -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" },