@2en/clawly-plugins 1.16.1 → 1.16.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.
package/auto-pair.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type {PluginApi} from './index'
2
+
3
+ const AUTO_APPROVE_CLIENT_IDS = new Set(['openclaw-ios'])
4
+ const POLL_INTERVAL_MS = 3_000
5
+
6
+ type PendingRequest = {
7
+ requestId: string
8
+ deviceId: string
9
+ clientId?: string
10
+ displayName?: string
11
+ platform?: string
12
+ }
13
+
14
+ type PairedDevice = {
15
+ deviceId: string
16
+ displayName?: string
17
+ platform?: string
18
+ }
19
+
20
+ type DevicePairingList = {
21
+ pending: PendingRequest[]
22
+ paired: PairedDevice[]
23
+ }
24
+
25
+ export function registerAutoPair(api: PluginApi) {
26
+ let timer: ReturnType<typeof setInterval> | null = null
27
+ let sdk: {
28
+ listDevicePairing: () => Promise<DevicePairingList>
29
+ approveDevicePairing: (
30
+ requestId: string,
31
+ ) => Promise<{requestId: string; device: PairedDevice} | null>
32
+ } | null = null
33
+
34
+ api.on('gateway_start', async () => {
35
+ try {
36
+ sdk = await import('openclaw/plugin-sdk')
37
+ } catch {
38
+ api.logger.warn('auto-pair: openclaw/plugin-sdk not available, skipping')
39
+ return
40
+ }
41
+
42
+ api.logger.info('auto-pair: started polling for pending pairing requests')
43
+
44
+ timer = setInterval(async () => {
45
+ if (!sdk) return
46
+ try {
47
+ const {pending} = await sdk.listDevicePairing()
48
+ for (const req of pending) {
49
+ if (req.clientId && AUTO_APPROVE_CLIENT_IDS.has(req.clientId)) {
50
+ const result = await sdk.approveDevicePairing(req.requestId)
51
+ if (result) {
52
+ api.logger.info(
53
+ `auto-pair: approved device=${result.device.deviceId} ` +
54
+ `name=${result.device.displayName ?? 'unknown'} ` +
55
+ `platform=${result.device.platform ?? 'unknown'}`,
56
+ )
57
+ }
58
+ }
59
+ }
60
+ } catch (err) {
61
+ api.logger.warn(`auto-pair: poll error: ${String(err)}`)
62
+ }
63
+ }, POLL_INTERVAL_MS)
64
+ })
65
+
66
+ api.on('gateway_stop', () => {
67
+ if (timer) {
68
+ clearInterval(timer)
69
+ timer = null
70
+ }
71
+ })
72
+ }
package/gateway/index.ts CHANGED
@@ -5,6 +5,7 @@ import {registerClawhub2gateway} from './clawhub2gateway'
5
5
  import {registerInject} from './inject'
6
6
  import {registerMemoryBrowser} from './memory'
7
7
  import {registerNotification} from './notification'
8
+ import {registerOfflinePush} from './offline-push'
8
9
  import {registerPlugins} from './plugins'
9
10
  import {registerPresence} from './presence'
10
11
 
@@ -17,4 +18,5 @@ export function registerGateway(api: PluginApi) {
17
18
  registerClawhub2gateway(api)
18
19
  registerPlugins(api)
19
20
  registerChannelsConfigure(api)
21
+ registerOfflinePush(api)
20
22
  }
package/gateway/memory.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * - clawly.workspace.get — read a .md file from workspace root
9
9
  */
10
10
 
11
+ import fsSync from 'node:fs'
11
12
  import fs from 'node:fs/promises'
12
13
  import os from 'node:os'
13
14
  import path from 'node:path'
@@ -39,6 +40,37 @@ function coercePluginConfig(api: PluginApi): Record<string, unknown> {
39
40
  return isRecord(api.pluginConfig) ? api.pluginConfig : {}
40
41
  }
41
42
 
43
+ function resolveStateDir(api: PluginApi): string {
44
+ return api.runtime.state?.resolveStateDir?.(process.env) ?? process.env.OPENCLAW_STATE_DIR ?? ''
45
+ }
46
+
47
+ /** Read the default agent's workspace path from openclaw.json (cached). */
48
+ let _cachedAgentWorkspace: string | null | undefined
49
+ function readAgentWorkspace(api: PluginApi): string | null {
50
+ if (_cachedAgentWorkspace !== undefined) return _cachedAgentWorkspace
51
+ try {
52
+ const stateDir = resolveStateDir(api)
53
+ if (!stateDir) {
54
+ _cachedAgentWorkspace = null
55
+ return null
56
+ }
57
+ const raw = fsSync.readFileSync(path.join(stateDir, 'openclaw.json'), 'utf-8')
58
+ const config = JSON.parse(raw)
59
+ const agents = config?.agents?.list
60
+ if (!Array.isArray(agents)) {
61
+ _cachedAgentWorkspace = null
62
+ return null
63
+ }
64
+ const defaultAgent = agents.find((a: any) => a.default) ?? agents[0]
65
+ const ws = typeof defaultAgent?.workspace === 'string' ? defaultAgent.workspace : null
66
+ _cachedAgentWorkspace = ws
67
+ return ws
68
+ } catch {
69
+ _cachedAgentWorkspace = null
70
+ return null
71
+ }
72
+ }
73
+
42
74
  /** Resolve the workspace root directory (without /memory suffix). */
43
75
  function resolveWorkspaceRoot(api: PluginApi, profile?: string): string {
44
76
  const cfg = coercePluginConfig(api)
@@ -46,7 +78,9 @@ function resolveWorkspaceRoot(api: PluginApi, profile?: string): string {
46
78
  if (configPath) return path.dirname(configPath) // strip /memory if configured
47
79
 
48
80
  const baseDir =
49
- process.env.OPENCLAW_WORKSPACE ?? path.join(os.homedir(), '.openclaw', 'workspace')
81
+ process.env.OPENCLAW_WORKSPACE ??
82
+ readAgentWorkspace(api) ??
83
+ path.join(os.homedir(), '.openclaw', 'workspace')
50
84
  if (profile && profile !== 'main') {
51
85
  const parentDir = path.dirname(baseDir)
52
86
  const baseName = path.basename(baseDir)
@@ -0,0 +1,300 @@
1
+ import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'
2
+ import type {PluginApi} from '../index'
3
+ import {
4
+ PUSH_COOLDOWN_S,
5
+ _resetCooldowns,
6
+ getLastAssistantPreview,
7
+ registerOfflinePush,
8
+ } from './offline-push'
9
+
10
+ // ── Mocks ────────────────────────────────────────────────────────
11
+
12
+ let mockOnline = false
13
+ let mockPushSent = true
14
+ let lastPushOpts: {body: string; title?: string; data?: Record<string, unknown>} | null = null
15
+ let mockIdentity: {name?: string; emoji?: string} | null = null
16
+
17
+ mock.module('./presence', () => ({
18
+ isClientOnline: async () => mockOnline,
19
+ }))
20
+
21
+ mock.module('./notification', () => ({
22
+ sendPushNotification: async (opts: {
23
+ body: string
24
+ title?: string
25
+ data?: Record<string, unknown>
26
+ }) => {
27
+ lastPushOpts = opts
28
+ return mockPushSent
29
+ },
30
+ }))
31
+
32
+ mock.module('./offline-push', () => {
33
+ const original = require('./offline-push')
34
+ return {
35
+ ...original,
36
+ getAgentIdentity: async () => mockIdentity,
37
+ }
38
+ })
39
+
40
+ // ── Helpers ──────────────────────────────────────────────────────
41
+
42
+ function createMockApi(): {
43
+ api: PluginApi
44
+ logs: {level: string; msg: string}[]
45
+ handlers: Map<string, (event: Record<string, unknown>) => Promise<void>>
46
+ } {
47
+ const logs: {level: string; msg: string}[] = []
48
+ const handlers = new Map<string, (event: Record<string, unknown>) => Promise<void>>()
49
+ const api = {
50
+ id: 'test',
51
+ name: 'test',
52
+ logger: {
53
+ info: (msg: string) => logs.push({level: 'info', msg}),
54
+ warn: (msg: string) => logs.push({level: 'warn', msg}),
55
+ error: (msg: string) => logs.push({level: 'error', msg}),
56
+ },
57
+ on: (hookName: string, handler: (...args: any[]) => any) => {
58
+ handlers.set(hookName, handler as any)
59
+ },
60
+ } as unknown as PluginApi
61
+ return {api, logs, handlers}
62
+ }
63
+
64
+ // ── Tests ────────────────────────────────────────────────────────
65
+
66
+ beforeEach(() => {
67
+ mockOnline = false
68
+ mockPushSent = true
69
+ mockIdentity = null
70
+ lastPushOpts = null
71
+ _resetCooldowns()
72
+ })
73
+
74
+ describe('offline-push', () => {
75
+ test('sends push when client is offline', async () => {
76
+ const {api, logs, handlers} = createMockApi()
77
+ registerOfflinePush(api)
78
+
79
+ const handler = handlers.get('agent_end')!
80
+ await handler({sessionKey: 'sess-1'})
81
+
82
+ expect(logs).toContainEqual({
83
+ level: 'info',
84
+ msg: expect.stringContaining('notified (session=sess-1)'),
85
+ })
86
+ })
87
+
88
+ test('skips push when client is online', async () => {
89
+ mockOnline = true
90
+ const {api, logs, handlers} = createMockApi()
91
+ registerOfflinePush(api)
92
+
93
+ await handlers.get('agent_end')!({sessionKey: 'sess-1'})
94
+
95
+ expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
96
+ expect(logs.filter((l) => l.msg.includes('skipped'))).toHaveLength(0)
97
+ })
98
+
99
+ test('respects per-session cooldown', async () => {
100
+ const {api, logs, handlers} = createMockApi()
101
+ registerOfflinePush(api)
102
+
103
+ const handler = handlers.get('agent_end')!
104
+
105
+ // First call — should send
106
+ await handler({sessionKey: 'sess-1'})
107
+ expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(1)
108
+
109
+ // Second call same session — should be cooldown-skipped
110
+ await handler({sessionKey: 'sess-1'})
111
+ expect(logs).toContainEqual({
112
+ level: 'info',
113
+ msg: expect.stringContaining('skipped (cooldown, session=sess-1)'),
114
+ })
115
+ })
116
+
117
+ test('different sessions have independent cooldowns', async () => {
118
+ const {api, logs, handlers} = createMockApi()
119
+ registerOfflinePush(api)
120
+
121
+ const handler = handlers.get('agent_end')!
122
+
123
+ await handler({sessionKey: 'sess-1'})
124
+ await handler({sessionKey: 'sess-2'})
125
+
126
+ const notified = logs.filter((l) => l.msg.includes('notified'))
127
+ expect(notified).toHaveLength(2)
128
+ expect(notified[0].msg).toContain('sess-1')
129
+ expect(notified[1].msg).toContain('sess-2')
130
+ })
131
+
132
+ test('uses __global__ key when sessionKey is missing', async () => {
133
+ const {api, logs, handlers} = createMockApi()
134
+ registerOfflinePush(api)
135
+
136
+ const handler = handlers.get('agent_end')!
137
+ await handler({})
138
+ await handler({})
139
+
140
+ expect(logs).toContainEqual({
141
+ level: 'info',
142
+ msg: expect.stringContaining('notified (session=unknown)'),
143
+ })
144
+ expect(logs).toContainEqual({
145
+ level: 'info',
146
+ msg: expect.stringContaining('skipped (cooldown, session=__global__)'),
147
+ })
148
+ })
149
+
150
+ test('does not update cooldown when push is not sent', async () => {
151
+ mockPushSent = false
152
+ const {api, logs, handlers} = createMockApi()
153
+ registerOfflinePush(api)
154
+
155
+ const handler = handlers.get('agent_end')!
156
+
157
+ // Push returns false (e.g. no token) — cooldown should NOT be set
158
+ await handler({sessionKey: 'sess-1'})
159
+ expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
160
+
161
+ // Second call should NOT be cooldown-skipped since first wasn't sent
162
+ mockPushSent = true
163
+ await handler({sessionKey: 'sess-1'})
164
+ expect(logs).toContainEqual({
165
+ level: 'info',
166
+ msg: expect.stringContaining('notified (session=sess-1)'),
167
+ })
168
+ })
169
+
170
+ test('exports PUSH_COOLDOWN_S as 30', () => {
171
+ expect(PUSH_COOLDOWN_S).toBe(30)
172
+ })
173
+
174
+ // ── Rich title/body tests ────────────────────────────────────
175
+
176
+ test('title shows agent emoji + name when identity is available', async () => {
177
+ mockIdentity = {name: 'Luna', emoji: '🐱'}
178
+ const {api, handlers} = createMockApi()
179
+ registerOfflinePush(api)
180
+
181
+ await handlers.get('agent_end')!({sessionKey: 'sess-1'})
182
+
183
+ expect(lastPushOpts?.title).toBe('🐱 Luna')
184
+ })
185
+
186
+ test('title falls back to 🦞 Clawly when identity fetch fails', async () => {
187
+ mockIdentity = null
188
+ const {api, handlers} = createMockApi()
189
+ registerOfflinePush(api)
190
+
191
+ await handlers.get('agent_end')!({sessionKey: 'sess-1'})
192
+
193
+ expect(lastPushOpts?.title).toBe('🦞 Clawly')
194
+ })
195
+
196
+ test('title uses default emoji when identity has name but no emoji', async () => {
197
+ mockIdentity = {name: 'Atlas'}
198
+ const {api, handlers} = createMockApi()
199
+ registerOfflinePush(api)
200
+
201
+ await handlers.get('agent_end')!({sessionKey: 'sess-1'})
202
+
203
+ expect(lastPushOpts?.title).toBe('🦞 Atlas')
204
+ })
205
+
206
+ test('body shows assistant message preview when messages are present', async () => {
207
+ const {api, handlers} = createMockApi()
208
+ registerOfflinePush(api)
209
+
210
+ await handlers.get('agent_end')!({
211
+ sessionKey: 'sess-1',
212
+ messages: [
213
+ {role: 'user', content: 'Hello'},
214
+ {role: 'assistant', content: 'Hi there! How can I help you today?'},
215
+ ],
216
+ })
217
+
218
+ expect(lastPushOpts?.body).toBe('Hi there! How can I help you today?')
219
+ })
220
+
221
+ test('body falls back when no messages', async () => {
222
+ const {api, handlers} = createMockApi()
223
+ registerOfflinePush(api)
224
+
225
+ await handlers.get('agent_end')!({sessionKey: 'sess-1'})
226
+
227
+ expect(lastPushOpts?.body).toBe('Your response is ready')
228
+ })
229
+
230
+ test('body falls back when messages has no assistant role', async () => {
231
+ const {api, handlers} = createMockApi()
232
+ registerOfflinePush(api)
233
+
234
+ await handlers.get('agent_end')!({
235
+ sessionKey: 'sess-1',
236
+ messages: [{role: 'user', content: 'Hello'}],
237
+ })
238
+
239
+ expect(lastPushOpts?.body).toBe('Your response is ready')
240
+ })
241
+ })
242
+
243
+ // ── getLastAssistantPreview unit tests ──────────────────────────
244
+
245
+ describe('getLastAssistantPreview', () => {
246
+ test('returns null for non-array input', () => {
247
+ expect(getLastAssistantPreview(undefined)).toBeNull()
248
+ expect(getLastAssistantPreview(null)).toBeNull()
249
+ expect(getLastAssistantPreview('string')).toBeNull()
250
+ })
251
+
252
+ test('extracts string content from last assistant message', () => {
253
+ const messages = [
254
+ {role: 'user', content: 'Hi'},
255
+ {role: 'assistant', content: 'First reply'},
256
+ {role: 'user', content: 'Another question'},
257
+ {role: 'assistant', content: 'Second reply'},
258
+ ]
259
+ expect(getLastAssistantPreview(messages)).toBe('Second reply')
260
+ })
261
+
262
+ test('handles array content with text parts', () => {
263
+ const messages = [
264
+ {
265
+ role: 'assistant',
266
+ content: [
267
+ {type: 'text', text: 'Hello '},
268
+ {type: 'text', text: 'world!'},
269
+ ],
270
+ },
271
+ ]
272
+ expect(getLastAssistantPreview(messages)).toBe('Hello world!')
273
+ })
274
+
275
+ test('collapses newlines into spaces', () => {
276
+ const messages = [{role: 'assistant', content: 'Line one\n\nLine two\nLine three'}]
277
+ expect(getLastAssistantPreview(messages)).toBe('Line one Line two Line three')
278
+ })
279
+
280
+ test('truncates long messages with ellipsis', () => {
281
+ const longText = 'a'.repeat(200)
282
+ const messages = [{role: 'assistant', content: longText}]
283
+ const result = getLastAssistantPreview(messages, 140)!
284
+ expect(result.length).toBe(141) // 140 chars + "…"
285
+ expect(result.endsWith('…')).toBe(true)
286
+ })
287
+
288
+ test('skips assistant messages with empty content', () => {
289
+ const messages = [
290
+ {role: 'assistant', content: 'Good reply'},
291
+ {role: 'assistant', content: ''},
292
+ ]
293
+ expect(getLastAssistantPreview(messages)).toBe('Good reply')
294
+ })
295
+
296
+ test('returns null when no assistant messages exist', () => {
297
+ const messages = [{role: 'user', content: 'Hello'}]
298
+ expect(getLastAssistantPreview(messages)).toBeNull()
299
+ })
300
+ })
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Offline push notification on agent_end — sends a push notification
3
+ * when an agent run completes and the mobile client is disconnected.
4
+ *
5
+ * Hook: agent_end → check presence → send Expo push if offline
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
+
11
+ import {$} from 'zx'
12
+ import type {PluginApi} from '../index'
13
+ import {stripCliLogs} from '../lib/stripCliLogs'
14
+ import {sendPushNotification} from './notification'
15
+ import {isClientOnline} from './presence'
16
+
17
+ $.verbose = false
18
+
19
+ /** Minimum seconds between consecutive push notifications per session. */
20
+ export const PUSH_COOLDOWN_S = 30
21
+
22
+ /** Per-session cooldown tracker. Key = sessionKey (or "__global__" for unknown). */
23
+ const lastPushBySession = new Map<string, number>()
24
+
25
+ /**
26
+ * Fetch agent display identity (name, emoji) via gateway RPC.
27
+ * Returns null on failure so the caller can fall back to defaults.
28
+ */
29
+ export async function getAgentIdentity(
30
+ agentId: string | undefined,
31
+ ): Promise<{name?: string; emoji?: string} | null> {
32
+ if (!agentId) return null
33
+ try {
34
+ const rpcParams = JSON.stringify({agentId})
35
+ const result = await $`openclaw gateway call agent.identity.get --json --params ${rpcParams}`
36
+ const parsed = JSON.parse(stripCliLogs(result.stdout))
37
+ return {
38
+ name: typeof parsed.name === 'string' ? parsed.name : undefined,
39
+ emoji: typeof parsed.emoji === 'string' ? parsed.emoji : undefined,
40
+ }
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Extract the last assistant message text and truncate to `maxLen` chars.
48
+ * Handles both string content and `{type:'text', text}[]` content formats.
49
+ */
50
+ export function getLastAssistantPreview(messages: unknown, maxLen = 140): string | null {
51
+ if (!Array.isArray(messages)) return null
52
+
53
+ // Walk backwards to find the last assistant message
54
+ for (let i = messages.length - 1; i >= 0; i--) {
55
+ const msg = messages[i]
56
+ if (typeof msg !== 'object' || msg === null) continue
57
+ if ((msg as any).role !== 'assistant') continue
58
+
59
+ const content = (msg as any).content
60
+ let text: string | undefined
61
+
62
+ if (typeof content === 'string') {
63
+ text = content
64
+ } else if (Array.isArray(content)) {
65
+ // Concatenate all text parts
66
+ text = content
67
+ .filter((p: any) => typeof p === 'object' && p !== null && p.type === 'text')
68
+ .map((p: any) => p.text)
69
+ .join('')
70
+ }
71
+
72
+ if (!text) continue
73
+
74
+ // Collapse newlines → spaces, trim
75
+ const collapsed = text.replace(/\n+/g, ' ').trim()
76
+ if (!collapsed) continue
77
+
78
+ if (collapsed.length <= maxLen) return collapsed
79
+ return `${collapsed.slice(0, maxLen)}…`
80
+ }
81
+
82
+ return null
83
+ }
84
+
85
+ export function registerOfflinePush(api: PluginApi) {
86
+ api.on('agent_end', async (event: Record<string, unknown>) => {
87
+ try {
88
+ // Skip if client is still connected — they got the response in real-time.
89
+ const online = await isClientOnline()
90
+ if (online) return
91
+
92
+ const sessionKey = typeof event.sessionKey === 'string' ? event.sessionKey : undefined
93
+ const cooldownKey = sessionKey ?? '__global__'
94
+
95
+ // Cooldown: skip if a push was sent recently for this session
96
+ // (e.g. by clawly.agent.send).
97
+ const now = Date.now() / 1000
98
+ const lastPush = lastPushBySession.get(cooldownKey) ?? 0
99
+ if (now - lastPush < PUSH_COOLDOWN_S) {
100
+ api.logger.info(`offline-push: skipped (cooldown, session=${cooldownKey})`)
101
+ return
102
+ }
103
+
104
+ const agentId = typeof event.agentId === 'string' ? event.agentId : undefined
105
+ const identity = await getAgentIdentity(agentId)
106
+ const title = `${identity?.emoji ?? '🦞'} ${identity?.name ?? 'Clawly'}`
107
+ const preview = getLastAssistantPreview(event.messages, 140)
108
+ const body = preview ?? 'Your response is ready'
109
+
110
+ const sent = await sendPushNotification(
111
+ {
112
+ title,
113
+ body,
114
+ data: {
115
+ type: 'agent_end',
116
+ ...(sessionKey ? {sessionKey} : {}),
117
+ },
118
+ },
119
+ api,
120
+ )
121
+
122
+ if (sent) {
123
+ lastPushBySession.set(cooldownKey, Date.now() / 1000)
124
+ api.logger.info(`offline-push: notified (session=${sessionKey ?? 'unknown'})`)
125
+ }
126
+ } catch (err) {
127
+ api.logger.error(`offline-push: ${err instanceof Error ? err.message : String(err)}`)
128
+ }
129
+ })
130
+
131
+ api.logger.info('offline-push: registered agent_end hook')
132
+ }
133
+
134
+ /** @internal — exposed for testing */
135
+ export function _resetCooldowns() {
136
+ lastPushBySession.clear()
137
+ }
@@ -0,0 +1,32 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {isOnlineEntry} from './presence'
3
+
4
+ describe('isOnlineEntry', () => {
5
+ test('returns true for reason "foreground"', () => {
6
+ expect(isOnlineEntry({host: 'openclaw-ios', reason: 'foreground'})).toBe(true)
7
+ })
8
+
9
+ test('returns true for reason "connect" (backward compat)', () => {
10
+ expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(true)
11
+ })
12
+
13
+ test('returns false for reason "background"', () => {
14
+ expect(isOnlineEntry({host: 'openclaw-ios', reason: 'background'})).toBe(false)
15
+ })
16
+
17
+ test('returns false for reason "disconnect"', () => {
18
+ expect(isOnlineEntry({host: 'openclaw-ios', reason: 'disconnect'})).toBe(false)
19
+ })
20
+
21
+ test('returns false for undefined entry (host not found)', () => {
22
+ expect(isOnlineEntry(undefined)).toBe(false)
23
+ })
24
+
25
+ test('returns false for entry with no reason', () => {
26
+ expect(isOnlineEntry({host: 'openclaw-ios'})).toBe(false)
27
+ })
28
+
29
+ test('returns false for empty entry', () => {
30
+ expect(isOnlineEntry({})).toBe(false)
31
+ })
32
+ })
@@ -19,6 +19,12 @@ interface PresenceEntry {
19
19
  reason?: string
20
20
  }
21
21
 
22
+ /** Returns true if the presence entry indicates the client is actively connected. */
23
+ export function isOnlineEntry(entry: PresenceEntry | undefined): boolean {
24
+ if (!entry) return false
25
+ return entry.reason === 'foreground' || entry.reason === 'connect'
26
+ }
27
+
22
28
  /**
23
29
  * Shells out to `openclaw gateway call system-presence` and checks
24
30
  * whether the given host has a non-"disconnect" entry.
@@ -29,8 +35,7 @@ export async function isClientOnline(host = DEFAULT_HOST): Promise<boolean> {
29
35
  const jsonStr = stripCliLogs(result.stdout)
30
36
  const entries: PresenceEntry[] = JSON.parse(jsonStr)
31
37
  const entry = entries.find((e) => e.host === host)
32
- if (!entry) return false
33
- return entry.reason === 'connect'
38
+ return isOnlineEntry(entry)
34
39
  } catch {
35
40
  return false
36
41
  }
package/index.ts CHANGED
@@ -24,8 +24,11 @@
24
24
  * Hooks:
25
25
  * - tool_result_persist — copies TTS audio to persistent outbound directory
26
26
  * - before_tool_call — enforces delivery fields on cron.create
27
+ * - agent_end — sends push notification when client is offline
28
+ * - gateway_start — auto-approves device pairing for Clawly mobile clients (clientId: openclaw-ios)
27
29
  */
28
30
 
31
+ import {registerAutoPair} from './auto-pair'
29
32
  import {registerCalendar} from './calendar'
30
33
  import {registerClawlyCronChannel} from './channel'
31
34
  import {registerCommands} from './command'
@@ -115,6 +118,7 @@ export default {
115
118
  registerClawlyCronChannel(api)
116
119
  registerCronHook(api)
117
120
  registerGateway(api)
121
+ registerAutoPair(api)
118
122
  setupModelGateway(api)
119
123
 
120
124
  // Email & calendar (optional — requires skillGatewayBaseUrl + skillGatewayToken in config)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.16.1",
3
+ "version": "1.16.3",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -17,13 +17,13 @@
17
17
  "lib",
18
18
  "tools",
19
19
  "index.ts",
20
+ "auto-pair.ts",
20
21
  "calendar.ts",
21
22
  "channel.ts",
22
23
  "cron-hook.ts",
23
24
  "email.ts",
24
25
  "gateway-fetch.ts",
25
26
  "outbound.ts",
26
- "auto-pair.ts",
27
27
  "model-gateway-setup.ts",
28
28
  "openclaw.plugin.json"
29
29
  ],