@2en/clawly-plugins 1.18.1 → 1.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,11 @@ import fs from 'node:fs'
13
13
  import os from 'node:os'
14
14
  import path from 'node:path'
15
15
 
16
+ import {$} from 'zx'
16
17
  import type {PluginApi} from '../index'
18
+ import {stripCliLogs} from '../lib/stripCliLogs'
19
+
20
+ $.verbose = false
17
21
 
18
22
  const TOKEN_DIR = path.join(os.homedir(), '.openclaw', 'clawly')
19
23
  const TOKEN_FILE = path.join(TOKEN_DIR, 'expo-push-token.json')
@@ -42,8 +46,38 @@ export function getPushToken(): string | null {
42
46
  return null
43
47
  }
44
48
 
49
+ /**
50
+ * Fetch agent display identity (name, emoji) via gateway RPC.
51
+ * Returns null on failure so the caller can fall back to defaults.
52
+ */
53
+ export async function getAgentIdentity(
54
+ agentId: string | undefined,
55
+ ): Promise<{name?: string; emoji?: string} | null> {
56
+ if (!agentId) return null
57
+ try {
58
+ const rpcParams = JSON.stringify({agentId})
59
+ const result = await $`openclaw gateway call agent.identity.get --json --params ${rpcParams}`
60
+ const parsed = JSON.parse(stripCliLogs(result.stdout))
61
+ return {
62
+ name: typeof parsed.name === 'string' ? parsed.name : undefined,
63
+ emoji: typeof parsed.emoji === 'string' ? parsed.emoji : undefined,
64
+ }
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Build a push notification title from agent identity.
72
+ * Falls back to "🦞 Clawly" when identity is unavailable.
73
+ */
74
+ export async function resolveAgentTitle(agentId?: string): Promise<string> {
75
+ const identity = await getAgentIdentity(agentId)
76
+ return `${identity?.emoji ?? '🦞'} ${identity?.name ?? 'Clawly'}`
77
+ }
78
+
45
79
  export async function sendPushNotification(
46
- opts: {body: string; title?: string; data?: Record<string, unknown>},
80
+ opts: {body: string; title?: string; agentId?: string; data?: Record<string, unknown>},
47
81
  api: PluginApi,
48
82
  extras?: Record<string, unknown>,
49
83
  ): Promise<boolean> {
@@ -53,6 +87,8 @@ export async function sendPushNotification(
53
87
  return false
54
88
  }
55
89
 
90
+ const title = opts.title ?? (await resolveAgentTitle(opts.agentId))
91
+
56
92
  try {
57
93
  const res = await fetch(EXPO_PUSH_URL, {
58
94
  method: 'POST',
@@ -60,7 +96,7 @@ export async function sendPushNotification(
60
96
  body: JSON.stringify({
61
97
  to: token,
62
98
  sound: 'default',
63
- title: opts.title ?? 'Clawly',
99
+ title,
64
100
  body: opts.body,
65
101
  data: opts.data,
66
102
  ...extras,
@@ -1,8 +1,6 @@
1
1
  import {beforeEach, describe, expect, mock, test} from 'bun:test'
2
2
  import type {PluginApi} from '../index'
3
3
  import {
4
- PUSH_COOLDOWN_S,
5
- _resetCooldowns,
6
4
  getLastAssistantPreview,
7
5
  getLastAssistantText,
8
6
  registerOfflinePush,
@@ -13,41 +11,41 @@ import {
13
11
 
14
12
  let mockOnline = false
15
13
  let mockPushSent = true
16
- let lastPushOpts: {body: string; title?: string; data?: Record<string, unknown>} | null = null
17
- let mockIdentity: {name?: string; emoji?: string} | null = null
14
+ let lastPushOpts: {
15
+ body: string
16
+ title?: string
17
+ agentId?: string
18
+ data?: Record<string, unknown>
19
+ } | null = null
20
+ let lastPushExtras: Record<string, unknown> | undefined = undefined
18
21
 
19
22
  mock.module('./presence', () => ({
20
23
  isClientOnline: async () => mockOnline,
21
24
  }))
22
25
 
23
26
  mock.module('./notification', () => ({
24
- sendPushNotification: async (opts: {
25
- body: string
26
- title?: string
27
- data?: Record<string, unknown>
28
- }) => {
27
+ sendPushNotification: async (
28
+ opts: {body: string; title?: string; agentId?: string; data?: Record<string, unknown>},
29
+ _api: PluginApi,
30
+ extras?: Record<string, unknown>,
31
+ ) => {
29
32
  lastPushOpts = opts
33
+ lastPushExtras = extras
30
34
  return mockPushSent
31
35
  },
32
36
  }))
33
37
 
34
- mock.module('./offline-push', () => {
35
- const original = require('./offline-push')
36
- return {
37
- ...original,
38
- getAgentIdentity: async () => mockIdentity,
39
- }
40
- })
41
-
42
38
  // ── Helpers ──────────────────────────────────────────────────────
43
39
 
40
+ type Handler = (event: Record<string, unknown>, ctx?: Record<string, unknown>) => Promise<void>
41
+
44
42
  function createMockApi(): {
45
43
  api: PluginApi
46
44
  logs: {level: string; msg: string}[]
47
- handlers: Map<string, (event: Record<string, unknown>) => Promise<void>>
45
+ handlers: Map<string, Handler>
48
46
  } {
49
47
  const logs: {level: string; msg: string}[] = []
50
- const handlers = new Map<string, (event: Record<string, unknown>) => Promise<void>>()
48
+ const handlers = new Map<string, Handler>()
51
49
  const api = {
52
50
  id: 'test',
53
51
  name: 'test',
@@ -68,9 +66,8 @@ function createMockApi(): {
68
66
  beforeEach(() => {
69
67
  mockOnline = false
70
68
  mockPushSent = true
71
- mockIdentity = null
72
69
  lastPushOpts = null
73
- _resetCooldowns()
70
+ lastPushExtras = undefined
74
71
  })
75
72
 
76
73
  describe('offline-push', () => {
@@ -79,7 +76,7 @@ describe('offline-push', () => {
79
76
  registerOfflinePush(api)
80
77
 
81
78
  const handler = handlers.get('agent_end')!
82
- await handler({sessionKey: 'sess-1'})
79
+ await handler({}, {sessionKey: 'sess-1'})
83
80
 
84
81
  expect(logs).toContainEqual({
85
82
  level: 'info',
@@ -92,130 +89,68 @@ describe('offline-push', () => {
92
89
  const {api, logs, handlers} = createMockApi()
93
90
  registerOfflinePush(api)
94
91
 
95
- await handlers.get('agent_end')!({sessionKey: 'sess-1'})
92
+ await handlers.get('agent_end')!({}, {sessionKey: 'sess-1'})
96
93
 
97
94
  expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
98
95
  expect(logs.filter((l) => l.msg.includes('skipped'))).toHaveLength(0)
99
96
  })
100
97
 
101
- test('respects per-session cooldown', async () => {
98
+ test('sends push for consecutive calls on same session', async () => {
102
99
  const {api, logs, handlers} = createMockApi()
103
100
  registerOfflinePush(api)
104
101
 
105
102
  const handler = handlers.get('agent_end')!
106
103
 
107
- // First call — should send
108
- await handler({sessionKey: 'sess-1'})
109
- expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(1)
104
+ await handler({}, {sessionKey: 'sess-1'})
105
+ await handler({}, {sessionKey: 'sess-1'})
110
106
 
111
- // Second call same session — should be cooldown-skipped
112
- await handler({sessionKey: 'sess-1'})
113
- expect(logs).toContainEqual({
114
- level: 'info',
115
- msg: expect.stringContaining('skipped (cooldown, session=sess-1)'),
116
- })
107
+ expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(2)
117
108
  })
118
109
 
119
- test('different sessions have independent cooldowns', async () => {
110
+ test('sends push when ctx is missing', async () => {
120
111
  const {api, logs, handlers} = createMockApi()
121
112
  registerOfflinePush(api)
122
113
 
123
114
  const handler = handlers.get('agent_end')!
124
-
125
- await handler({sessionKey: 'sess-1'})
126
- await handler({sessionKey: 'sess-2'})
127
-
128
- const notified = logs.filter((l) => l.msg.includes('notified'))
129
- expect(notified).toHaveLength(2)
130
- expect(notified[0].msg).toContain('sess-1')
131
- expect(notified[1].msg).toContain('sess-2')
132
- })
133
-
134
- test('uses __global__ key when sessionKey is missing', async () => {
135
- const {api, logs, handlers} = createMockApi()
136
- registerOfflinePush(api)
137
-
138
- const handler = handlers.get('agent_end')!
139
- await handler({})
140
115
  await handler({})
141
116
 
142
117
  expect(logs).toContainEqual({
143
118
  level: 'info',
144
119
  msg: expect.stringContaining('notified (session=unknown)'),
145
120
  })
146
- expect(logs).toContainEqual({
147
- level: 'info',
148
- msg: expect.stringContaining('skipped (cooldown, session=__global__)'),
149
- })
150
121
  })
151
122
 
152
- test('does not update cooldown when push is not sent', async () => {
153
- mockPushSent = false
154
- const {api, logs, handlers} = createMockApi()
155
- registerOfflinePush(api)
156
-
157
- const handler = handlers.get('agent_end')!
158
-
159
- // Push returns false (e.g. no token) — cooldown should NOT be set
160
- await handler({sessionKey: 'sess-1'})
161
- expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
162
-
163
- // Second call should NOT be cooldown-skipped since first wasn't sent
164
- mockPushSent = true
165
- await handler({sessionKey: 'sess-1'})
166
- expect(logs).toContainEqual({
167
- level: 'info',
168
- msg: expect.stringContaining('notified (session=sess-1)'),
169
- })
170
- })
171
-
172
- test('exports PUSH_COOLDOWN_S as 30', () => {
173
- expect(PUSH_COOLDOWN_S).toBe(30)
174
- })
175
-
176
- // ── Rich title/body tests ────────────────────────────────────
177
-
178
- test('title shows agent emoji + name when identity is available', async () => {
179
- mockIdentity = {name: 'Luna', emoji: '🐱'}
180
- const {api, handlers} = createMockApi()
181
- registerOfflinePush(api)
182
-
183
- await handlers.get('agent_end')!({sessionKey: 'sess-1'})
184
-
185
- expect(lastPushOpts?.title).toBe('🐱 Luna')
186
- })
187
-
188
- test('title falls back to 🦞 Clawly when identity fetch fails', async () => {
189
- mockIdentity = null
123
+ test('passes agentId from ctx to sendPushNotification', async () => {
190
124
  const {api, handlers} = createMockApi()
191
125
  registerOfflinePush(api)
192
126
 
193
- await handlers.get('agent_end')!({sessionKey: 'sess-1'})
127
+ await handlers.get('agent_end')!({}, {sessionKey: 'sess-1', agentId: 'luna'})
194
128
 
195
- expect(lastPushOpts?.title).toBe('🦞 Clawly')
129
+ expect(lastPushOpts?.agentId).toBe('luna')
196
130
  })
197
131
 
198
- test('title uses default emoji when identity has name but no emoji', async () => {
199
- mockIdentity = {name: 'Atlas'}
132
+ test('does not pass title delegates to sendPushNotification', async () => {
200
133
  const {api, handlers} = createMockApi()
201
134
  registerOfflinePush(api)
202
135
 
203
- await handlers.get('agent_end')!({sessionKey: 'sess-1'})
136
+ await handlers.get('agent_end')!({}, {sessionKey: 'sess-1'})
204
137
 
205
- expect(lastPushOpts?.title).toBe('🦞 Atlas')
138
+ expect(lastPushOpts?.title).toBeUndefined()
206
139
  })
207
140
 
208
141
  test('body shows assistant message preview when messages are present', async () => {
209
142
  const {api, handlers} = createMockApi()
210
143
  registerOfflinePush(api)
211
144
 
212
- await handlers.get('agent_end')!({
213
- sessionKey: 'sess-1',
214
- messages: [
215
- {role: 'user', content: 'Hello'},
216
- {role: 'assistant', content: 'Hi there! How can I help you today?'},
217
- ],
218
- })
145
+ await handlers.get('agent_end')!(
146
+ {
147
+ messages: [
148
+ {role: 'user', content: 'Hello'},
149
+ {role: 'assistant', content: 'Hi there! How can I help you today?'},
150
+ ],
151
+ },
152
+ {sessionKey: 'sess-1'},
153
+ )
219
154
 
220
155
  expect(lastPushOpts?.body).toBe('Hi there! How can I help you today?')
221
156
  })
@@ -224,7 +159,7 @@ describe('offline-push', () => {
224
159
  const {api, handlers} = createMockApi()
225
160
  registerOfflinePush(api)
226
161
 
227
- await handlers.get('agent_end')!({sessionKey: 'sess-1'})
162
+ await handlers.get('agent_end')!({}, {sessionKey: 'sess-1'})
228
163
 
229
164
  expect(lastPushOpts?.body).toBe('Your response is ready')
230
165
  })
@@ -233,10 +168,10 @@ describe('offline-push', () => {
233
168
  const {api, handlers} = createMockApi()
234
169
  registerOfflinePush(api)
235
170
 
236
- await handlers.get('agent_end')!({
237
- sessionKey: 'sess-1',
238
- messages: [{role: 'user', content: 'Hello'}],
239
- })
171
+ await handlers.get('agent_end')!(
172
+ {messages: [{role: 'user', content: 'Hello'}]},
173
+ {sessionKey: 'sess-1'},
174
+ )
240
175
 
241
176
  expect(lastPushOpts?.body).toBe('Your response is ready')
242
177
  })
@@ -284,8 +219,6 @@ describe('getLastAssistantText', () => {
284
219
  {role: 'assistant', content: 'Good reply'},
285
220
  {role: 'assistant', content: ''},
286
221
  ]
287
- // Returns '' for the last assistant message (not fall-through to earlier)
288
- // so the push filter can detect and skip it
289
222
  expect(getLastAssistantText(messages)).toBe('')
290
223
  })
291
224
 
@@ -356,10 +289,10 @@ describe('offline-push with filtered messages', () => {
356
289
  const {api, logs, handlers} = createMockApi()
357
290
  registerOfflinePush(api)
358
291
 
359
- await handlers.get('agent_end')!({
360
- sessionKey: 'sess-1',
361
- messages: [{role: 'assistant', content: 'NO_REPLY'}],
362
- })
292
+ await handlers.get('agent_end')!(
293
+ {messages: [{role: 'assistant', content: 'NO_REPLY'}]},
294
+ {sessionKey: 'sess-1'},
295
+ )
363
296
 
364
297
  expect(logs).toContainEqual({
365
298
  level: 'info',
@@ -372,10 +305,10 @@ describe('offline-push with filtered messages', () => {
372
305
  const {api, logs, handlers} = createMockApi()
373
306
  registerOfflinePush(api)
374
307
 
375
- await handlers.get('agent_end')!({
376
- sessionKey: 'sess-1',
377
- messages: [{role: 'assistant', content: 'HEARTBEAT_OK'}],
378
- })
308
+ await handlers.get('agent_end')!(
309
+ {messages: [{role: 'assistant', content: 'HEARTBEAT_OK'}]},
310
+ {sessionKey: 'sess-1'},
311
+ )
379
312
 
380
313
  expect(logs).toContainEqual({
381
314
  level: 'info',
@@ -388,10 +321,10 @@ describe('offline-push with filtered messages', () => {
388
321
  const {api, logs, handlers} = createMockApi()
389
322
  registerOfflinePush(api)
390
323
 
391
- await handlers.get('agent_end')!({
392
- sessionKey: 'sess-1',
393
- messages: [{role: 'assistant', content: ' '}],
394
- })
324
+ await handlers.get('agent_end')!(
325
+ {messages: [{role: 'assistant', content: ' '}]},
326
+ {sessionKey: 'sess-1'},
327
+ )
395
328
 
396
329
  expect(logs).toContainEqual({
397
330
  level: 'info',
@@ -403,10 +336,10 @@ describe('offline-push with filtered messages', () => {
403
336
  const {api, logs, handlers} = createMockApi()
404
337
  registerOfflinePush(api)
405
338
 
406
- await handlers.get('agent_end')!({
407
- sessionKey: 'sess-1',
408
- messages: [{role: 'assistant', content: 'I finished the task you asked about!'}],
409
- })
339
+ await handlers.get('agent_end')!(
340
+ {messages: [{role: 'assistant', content: 'I finished the task you asked about!'}]},
341
+ {sessionKey: 'sess-1'},
342
+ )
410
343
 
411
344
  expect(logs).toContainEqual({
412
345
  level: 'info',
@@ -418,8 +351,7 @@ describe('offline-push with filtered messages', () => {
418
351
  const {api, logs, handlers} = createMockApi()
419
352
  registerOfflinePush(api)
420
353
 
421
- // No messages field — can't determine if filtered, so send push
422
- await handlers.get('agent_end')!({sessionKey: 'sess-1'})
354
+ await handlers.get('agent_end')!({}, {sessionKey: 'sess-1'})
423
355
 
424
356
  expect(logs).toContainEqual({
425
357
  level: 'info',
@@ -4,49 +4,15 @@
4
4
  *
5
5
  * Hook: agent_end → check presence → filter noise → send Expo push if offline
6
6
  *
7
- * Avoids double-push with clawly.agent.send (which pushes at dispatch
8
- * time for cron/external triggers) via a 30-second cooldown window.
9
- *
10
7
  * Skips push for messages the mobile UI would filter (heartbeat acks,
11
8
  * NO_REPLY, compaction markers, etc.) — mirrors shouldFilterMessage()
12
9
  * logic from apps/mobile/lib/messageFilter.ts.
13
10
  */
14
11
 
15
- import {$} from 'zx'
16
12
  import type {PluginApi} from '../index'
17
- import {stripCliLogs} from '../lib/stripCliLogs'
18
13
  import {sendPushNotification} from './notification'
19
14
  import {isClientOnline} from './presence'
20
15
 
21
- $.verbose = false
22
-
23
- /** Minimum seconds between consecutive push notifications per session. */
24
- export const PUSH_COOLDOWN_S = 30
25
-
26
- /** Per-session cooldown tracker. Key = sessionKey (or "__global__" for unknown). */
27
- const lastPushBySession = new Map<string, number>()
28
-
29
- /**
30
- * Fetch agent display identity (name, emoji) via gateway RPC.
31
- * Returns null on failure so the caller can fall back to defaults.
32
- */
33
- export async function getAgentIdentity(
34
- agentId: string | undefined,
35
- ): Promise<{name?: string; emoji?: string} | null> {
36
- if (!agentId) return null
37
- try {
38
- const rpcParams = JSON.stringify({agentId})
39
- const result = await $`openclaw gateway call agent.identity.get --json --params ${rpcParams}`
40
- const parsed = JSON.parse(stripCliLogs(result.stdout))
41
- return {
42
- name: typeof parsed.name === 'string' ? parsed.name : undefined,
43
- emoji: typeof parsed.emoji === 'string' ? parsed.emoji : undefined,
44
- }
45
- } catch {
46
- return null
47
- }
48
- }
49
-
50
16
  /**
51
17
  * Extract the last assistant message's full text from a messages array.
52
18
  * Handles both string and `{type:'text', text}[]` content formats.
@@ -123,7 +89,7 @@ export function shouldSkipPushForMessage(text: string): string | null {
123
89
  }
124
90
 
125
91
  export function registerOfflinePush(api: PluginApi) {
126
- api.on('agent_end', async (event: Record<string, unknown>) => {
92
+ api.on('agent_end', async (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
127
93
  try {
128
94
  // Skip if client is still connected — they got the response in real-time.
129
95
  const online = await isClientOnline()
@@ -141,28 +107,17 @@ export function registerOfflinePush(api: PluginApi) {
141
107
  }
142
108
  }
143
109
 
144
- const sessionKey = typeof event.sessionKey === 'string' ? event.sessionKey : undefined
145
- const cooldownKey = sessionKey ?? '__global__'
110
+ // agentId and sessionKey live on ctx (PluginHookAgentContext), not event.
111
+ const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
112
+ const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
146
113
 
147
- // Cooldown: skip if a push was sent recently for this session
148
- // (e.g. by clawly.agent.send).
149
- const now = Date.now() / 1000
150
- const lastPush = lastPushBySession.get(cooldownKey) ?? 0
151
- if (now - lastPush < PUSH_COOLDOWN_S) {
152
- api.logger.info(`offline-push: skipped (cooldown, session=${cooldownKey})`)
153
- return
154
- }
155
-
156
- const agentId = typeof event.agentId === 'string' ? event.agentId : undefined
157
- const identity = await getAgentIdentity(agentId)
158
- const title = `${identity?.emoji ?? '🦞'} ${identity?.name ?? 'Clawly'}`
159
114
  const preview = fullText && fullText.length > 140 ? `${fullText.slice(0, 140)}…` : fullText
160
115
  const body = preview ?? 'Your response is ready'
161
116
 
162
117
  const sent = await sendPushNotification(
163
118
  {
164
- title,
165
119
  body,
120
+ agentId,
166
121
  data: {
167
122
  type: 'agent_end',
168
123
  ...(sessionKey ? {sessionKey} : {}),
@@ -172,7 +127,6 @@ export function registerOfflinePush(api: PluginApi) {
172
127
  )
173
128
 
174
129
  if (sent) {
175
- lastPushBySession.set(cooldownKey, Date.now() / 1000)
176
130
  api.logger.info(`offline-push: notified (session=${sessionKey ?? 'unknown'})`)
177
131
  }
178
132
  } catch (err) {
@@ -182,8 +136,3 @@ export function registerOfflinePush(api: PluginApi) {
182
136
 
183
137
  api.logger.info('offline-push: registered agent_end hook')
184
138
  }
185
-
186
- /** @internal — exposed for testing */
187
- export function _resetCooldowns() {
188
- lastPushBySession.clear()
189
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.18.1",
3
+ "version": "1.18.2",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {