@pnds/pond 0.1.0 → 0.2.0

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.
@@ -3,24 +3,7 @@
3
3
  "channels": ["pond"],
4
4
  "configSchema": {
5
5
  "type": "object",
6
- "additionalProperties": false,
7
- "required": ["pond_url", "api_key", "org_id"],
8
- "properties": {
9
- "pond_url": {
10
- "type": "string",
11
- "minLength": 1,
12
- "description": "Pond API base URL"
13
- },
14
- "api_key": {
15
- "type": "string",
16
- "minLength": 1,
17
- "description": "Agent API key (agk_xxx)"
18
- },
19
- "org_id": {
20
- "type": "string",
21
- "minLength": 1,
22
- "description": "Organisation ID"
23
- }
24
- }
6
+ "additionalProperties": true,
7
+ "properties": {}
25
8
  }
26
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnds/pond",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "OpenClaw channel plugin for Pond IM",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,7 +14,7 @@
14
14
  "openclaw.plugin.json"
15
15
  ],
16
16
  "dependencies": {
17
- "@pnds/sdk": "^0.1.0"
17
+ "@pnds/sdk": "^0.1.1"
18
18
  },
19
19
  "devDependencies": {
20
20
  "typescript": "^5.7.0"
package/src/channel.ts CHANGED
@@ -20,7 +20,7 @@ export const pondPlugin: ChannelPlugin<ResolvedPondAccount> = {
20
20
  chatTypes: ["direct", "group"],
21
21
  polls: false,
22
22
  threads: false,
23
- media: false,
23
+ media: true,
24
24
  reactions: false,
25
25
  edit: false,
26
26
  reply: false,
@@ -0,0 +1,150 @@
1
+ import type { PondClient } from "@pnds/sdk";
2
+ import type { PlatformConfigResponse } from "@pnds/sdk";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+
6
+ interface CachedPlatformConfig {
7
+ version: string;
8
+ config: Record<string, unknown>;
9
+ fetchedAt: string;
10
+ }
11
+
12
+ interface ConfigManagerOpts {
13
+ client: PondClient;
14
+ stateDir: string;
15
+ configPath: string;
16
+ credentials: { api_key: string; pond_url: string };
17
+ log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
18
+ }
19
+
20
+ const CACHE_FILENAME = "pond-platform-config.json";
21
+
22
+ function cachePath(stateDir: string): string {
23
+ return path.join(stateDir, CACHE_FILENAME);
24
+ }
25
+
26
+ function loadCachedConfig(stateDir: string): CachedPlatformConfig | null {
27
+ try {
28
+ const raw = fs.readFileSync(cachePath(stateDir), "utf-8");
29
+ return JSON.parse(raw) as CachedPlatformConfig;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function saveCachedConfig(stateDir: string, config: PlatformConfigResponse): void {
36
+ const cached: CachedPlatformConfig = {
37
+ version: config.version,
38
+ config: config.config,
39
+ fetchedAt: new Date().toISOString(),
40
+ };
41
+ const filePath = cachePath(stateDir);
42
+ const tmpPath = `${filePath}.tmp`;
43
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
44
+ fs.writeFileSync(tmpPath, JSON.stringify(cached, null, 2), "utf-8");
45
+ fs.renameSync(tmpPath, filePath);
46
+ }
47
+
48
+ /**
49
+ * Deep-merge platform config into an existing openclaw.json file.
50
+ * Only overwrites `models.providers.pond` and `agents.defaults` sections;
51
+ * all other keys (user customizations, other providers) are preserved.
52
+ */
53
+ function applyToOpenClawConfig(
54
+ configPath: string,
55
+ platformConfig: Record<string, unknown>,
56
+ credentials: { api_key: string; pond_url: string },
57
+ ): void {
58
+ let existing: Record<string, unknown> = {};
59
+ try {
60
+ const raw = fs.readFileSync(configPath, "utf-8");
61
+ existing = JSON.parse(raw) as Record<string, unknown>;
62
+ } catch {
63
+ // File doesn't exist or is invalid — start fresh
64
+ }
65
+
66
+ // Deep-merge models.providers.pond (preserve existing fields, overlay platform, inject credentials)
67
+ const models = (existing.models ?? {}) as Record<string, unknown>;
68
+ const providers = (models.providers ?? {}) as Record<string, unknown>;
69
+ const existingPond = (providers.pond ?? {}) as Record<string, unknown>;
70
+ const platformModels = (platformConfig.models ?? {}) as Record<string, unknown>;
71
+ const platformProviders = (platformModels.providers ?? {}) as Record<string, unknown>;
72
+ const platformPond = (platformProviders.pond ?? {}) as Record<string, unknown>;
73
+
74
+ providers.pond = {
75
+ ...existingPond,
76
+ ...platformPond,
77
+ apiKey: credentials.api_key,
78
+ };
79
+ models.providers = providers;
80
+ existing.models = models;
81
+
82
+ // Deep-merge agents.defaults (preserve existing fields, overlay platform)
83
+ const platformAgents = (platformConfig.agents ?? {}) as Record<string, unknown>;
84
+ if (platformAgents.defaults !== undefined) {
85
+ const agents = (existing.agents ?? {}) as Record<string, unknown>;
86
+ const existingDefaults = (agents.defaults ?? {}) as Record<string, unknown>;
87
+ agents.defaults = {
88
+ ...existingDefaults,
89
+ ...(platformAgents.defaults as Record<string, unknown>),
90
+ };
91
+ existing.agents = agents;
92
+ }
93
+
94
+ // Atomic write
95
+ const tmpPath = `${configPath}.tmp`;
96
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
97
+ fs.writeFileSync(tmpPath, JSON.stringify(existing, null, 2), "utf-8");
98
+ fs.renameSync(tmpPath, configPath);
99
+ }
100
+
101
+ /**
102
+ * Fetch platform config from the server, cache it (LKG), and apply to openclaw.json.
103
+ * Degrades gracefully: uses cached config on fetch failure, continues if neither is available.
104
+ */
105
+ export async function fetchAndApplyPlatformConfig(opts: ConfigManagerOpts): Promise<void> {
106
+ const { client, stateDir, configPath, credentials, log } = opts;
107
+
108
+ // 1. Load cached config (LKG)
109
+ const cached = loadCachedConfig(stateDir);
110
+
111
+ // 2. Fetch from server (with version for 304)
112
+ let fresh: PlatformConfigResponse | null = null;
113
+ try {
114
+ fresh = await client.getPlatformConfig(cached?.version);
115
+ } catch (err) {
116
+ // Fetch failed — fall through to use cache
117
+ if (cached) {
118
+ log?.warn(`platform config fetch failed, using cached version ${cached.version}: ${String(err)}`);
119
+ applyToOpenClawConfig(configPath, cached.config, credentials);
120
+ return;
121
+ }
122
+ // No cache and fetch fails — degrade gracefully
123
+ log?.error(`platform config fetch failed and no cache available: ${String(err)}`);
124
+ return;
125
+ }
126
+
127
+ // 3. 304 — config unchanged
128
+ if (fresh === null) {
129
+ log?.info("platform config unchanged (304)");
130
+ if (cached) {
131
+ applyToOpenClawConfig(configPath, cached.config, credentials);
132
+ }
133
+ return;
134
+ }
135
+
136
+ // 4. Validate schema compatibility before accepting
137
+ const SUPPORTED_SCHEMA_VERSION = 1;
138
+ if (fresh.schema_version !== undefined && fresh.schema_version > SUPPORTED_SCHEMA_VERSION) {
139
+ log?.error(`platform config schema_version ${fresh.schema_version} is newer than supported (${SUPPORTED_SCHEMA_VERSION}), keeping current config`);
140
+ if (cached) {
141
+ applyToOpenClawConfig(configPath, cached.config, credentials);
142
+ }
143
+ return;
144
+ }
145
+
146
+ // 5. Newer config received — save cache and apply
147
+ log?.info(`platform config updated to version ${fresh.version}`);
148
+ saveCachedConfig(stateDir, fresh);
149
+ applyToOpenClawConfig(configPath, fresh.config, credentials);
150
+ }
package/src/gateway.ts CHANGED
@@ -1,11 +1,14 @@
1
- import type { ChannelGatewayAdapter } from "openclaw/plugin-sdk";
1
+ import type { ChannelGatewayAdapter, PluginRuntime } from "openclaw/plugin-sdk";
2
2
  import { PondClient, PondWs, MENTION_ALL_USER_ID, WS_EVENTS } from "@pnds/sdk";
3
- import type { Chat, MessageNewData, SendMessageRequest, TextContent, HelloData, ChatUpdateData } from "@pnds/sdk";
3
+ import type { Chat, MessageNewData, TextContent, MediaContent, HelloData, ChatUpdateData, AgentConfigUpdateData } from "@pnds/sdk";
4
+ import type { PondChannelConfig } from "./types.js";
4
5
  import * as os from "node:os";
6
+ import * as path from "node:path";
5
7
  import { resolvePondAccount } from "./accounts.js";
6
- import { getPondRuntime, setPondAccountState, removePondAccountState, setSessionChatId } from "./runtime.js";
8
+ import { getPondRuntime, setPondAccountState, getPondAccountState, removePondAccountState, setSessionChatId } from "./runtime.js";
7
9
  import { buildSessionKey } from "./session.js";
8
10
  import type { ResolvedPondAccount } from "./types.js";
11
+ import { fetchAndApplyPlatformConfig } from "./config-manager.js";
9
12
 
10
13
  function resolveWsUrl(account: ResolvedPondAccount): string {
11
14
  if (account.config.ws_url) return account.config.ws_url;
@@ -35,6 +38,65 @@ async function fetchAllChats(
35
38
  return total;
36
39
  }
37
40
 
41
+ /**
42
+ * Dispatch an inbound message to the OpenClaw agent and handle the reply.
43
+ * Shared by both text and file message handlers to avoid duplication.
44
+ */
45
+ async function dispatchToAgent(opts: {
46
+ core: PluginRuntime;
47
+ cfg: Record<string, unknown>;
48
+ client: PondClient;
49
+ config: PondChannelConfig;
50
+ agentUserId: string;
51
+ accountId: string;
52
+ chatId: string;
53
+ messageId: string;
54
+ inboundCtx: Record<string, unknown>;
55
+ log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
56
+ }) {
57
+ const { core, cfg, client, config, agentUserId, accountId, chatId, messageId, inboundCtx, log } = opts;
58
+ let thinkingSent = false;
59
+ try {
60
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
61
+ ctx: inboundCtx,
62
+ cfg,
63
+ dispatcherOptions: {
64
+ deliver: async (payload: { text?: string }) => {
65
+ const replyText = payload.text?.trim();
66
+ if (!replyText) return;
67
+ await client.sendMessage(config.org_id, chatId, {
68
+ message_type: "text",
69
+ content: { text: replyText },
70
+ });
71
+ },
72
+ onReplyStart: () => {
73
+ if (thinkingSent) return;
74
+ thinkingSent = true;
75
+ (async () => {
76
+ try {
77
+ const run = await client.createAgentRun(config.org_id, agentUserId, {
78
+ trigger_type: "mention",
79
+ trigger_ref: { message_id: messageId },
80
+ chat_id: chatId,
81
+ });
82
+ const state = getPondAccountState(accountId);
83
+ if (state) state.activeRunId = run.id;
84
+ await client.createAgentStep(config.org_id, agentUserId, run.id, {
85
+ step_type: "thinking",
86
+ content: { text: "" },
87
+ });
88
+ } catch (err) {
89
+ log?.warn(`pond[${accountId}]: failed to create agent run: ${String(err)}`);
90
+ }
91
+ })();
92
+ },
93
+ },
94
+ });
95
+ } catch (err) {
96
+ log?.error(`pond[${accountId}]: dispatch failed for ${messageId}: ${String(err)}`);
97
+ }
98
+ }
99
+
38
100
  export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
39
101
  startAccount: async (ctx) => {
40
102
  const account = resolvePondAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
@@ -55,6 +117,30 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
55
117
  const agentUserId = me.id;
56
118
  log?.info(`pond[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
57
119
 
120
+ // Inject env vars so child processes (e.g. @pnds/cli) inherit Pond credentials
121
+ process.env.POND_API_URL = config.pond_url;
122
+ process.env.POND_API_KEY = config.api_key;
123
+ process.env.POND_ORG_ID = config.org_id;
124
+
125
+ // Apply platform config (models, defaults)
126
+ // Use OPENCLAW_STATE_DIR if set, otherwise derive from HOME (systemd sets HOME=/data for hosted agents)
127
+ const stateDir = process.env.OPENCLAW_STATE_DIR
128
+ || path.join(process.env.HOME || "/data", ".openclaw");
129
+ const openclawConfigPath = path.join(stateDir, "openclaw.json");
130
+ const configManagerOpts = {
131
+ client,
132
+ stateDir,
133
+ configPath: openclawConfigPath,
134
+ credentials: { api_key: config.api_key, pond_url: config.pond_url },
135
+ log,
136
+ };
137
+
138
+ try {
139
+ await fetchAndApplyPlatformConfig(configManagerOpts);
140
+ } catch (err) {
141
+ log?.warn(`pond[${ctx.accountId}]: platform config fetch failed: ${String(err)}`);
142
+ }
143
+
58
144
  // Connect WebSocket via ticket auth (reconnection enabled by default in PondWs)
59
145
  const wsUrl = resolveWsUrl(account);
60
146
  const ws = new PondWs({
@@ -67,15 +153,18 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
67
153
 
68
154
  // Session ID from hello — used for heartbeat
69
155
  let sessionId = "";
156
+ // Heartbeat interval handle — created inside hello handler with server-provided interval
157
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
70
158
 
71
159
  // Log connection state changes (covers reconnection)
72
160
  ws.onStateChange((state) => {
73
161
  log?.info(`pond[${ctx.accountId}]: connection state → ${state}`);
74
162
  });
75
163
 
76
- // On hello, cache chat types (with pagination) and start heartbeat
164
+ // On hello, cache chat types and start heartbeat with server-provided interval
77
165
  ws.on("hello", async (data: HelloData) => {
78
166
  sessionId = data.session_id ?? "";
167
+ const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
79
168
  try {
80
169
  const count = await fetchAllChats(client, config.org_id, chatTypeMap);
81
170
  log?.info(`pond[${ctx.accountId}]: WebSocket connected, ${count} chats cached`);
@@ -86,6 +175,19 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
86
175
  orgId: config.org_id,
87
176
  agentUserId,
88
177
  });
178
+
179
+ // (Re)start heartbeat with server-provided interval
180
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
181
+ heartbeatTimer = setInterval(() => {
182
+ if (ws.state !== "connected") return;
183
+ ws.sendAgentHeartbeat(sessionId, {
184
+ hostname: os.hostname(),
185
+ cores: os.cpus().length,
186
+ mem_total: os.totalmem(),
187
+ mem_free: os.freemem(),
188
+ uptime: os.uptime(),
189
+ });
190
+ }, intervalSec * 1000);
89
191
  } catch (err) {
90
192
  log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
91
193
  }
@@ -98,14 +200,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
98
200
  }
99
201
  });
100
202
 
101
- // Handle inbound messages
203
+ // Handle inbound messages (text + file)
102
204
  ws.on("message.new", async (data: MessageNewData) => {
103
205
  if (data.sender_id === agentUserId) return;
104
- if (data.message_type !== "text") return;
105
-
106
- const content = data.content as TextContent;
107
- const text = content.text?.trim();
108
- if (!text) return;
206
+ if (data.message_type !== "text" && data.message_type !== "file") return;
109
207
 
110
208
  const chatId = data.chat_id;
111
209
 
@@ -122,13 +220,46 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
122
220
  }
123
221
  }
124
222
 
125
- // In group chats, only respond when @mentioned or @all
126
- if (chatType === "group") {
127
- const mentions = content.mentions ?? [];
128
- const isMentioned = mentions.some(
129
- (m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID
130
- );
131
- if (!isMentioned) return;
223
+ // Build body text and optional media fields based on message type
224
+ let body = "";
225
+ let mediaFields: Record<string, string | undefined> = {};
226
+
227
+ if (data.message_type === "text") {
228
+ const content = data.content as TextContent;
229
+ body = content.text?.trim() ?? "";
230
+ if (!body) return;
231
+
232
+ // In group chats, only respond when @mentioned or @all
233
+ if (chatType === "group") {
234
+ const mentions = content.mentions ?? [];
235
+ const isMentioned = mentions.some(
236
+ (m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID
237
+ );
238
+ if (!isMentioned) return;
239
+ }
240
+ } else {
241
+ // file message
242
+ const content = data.content as MediaContent;
243
+
244
+ // In group chats, file messages have no mentions field — skip
245
+ if (chatType === "group") return;
246
+
247
+ body = content.caption?.trim()
248
+ || `[file: ${content.file_name || "attachment"}]`;
249
+
250
+ // Resolve presigned download URL for the attachment
251
+ if (content.attachment_id) {
252
+ try {
253
+ const fileRes = await client.getFileUrl(content.attachment_id);
254
+ mediaFields = {
255
+ MediaUrl: fileRes.url,
256
+ MediaType: content.mime_type,
257
+ };
258
+ } catch (err) {
259
+ log?.warn(`pond[${ctx.accountId}]: failed to get file URL for ${content.attachment_id}: ${String(err)}`);
260
+ // Degrade gracefully — agent still gets the body text
261
+ }
262
+ }
132
263
  }
133
264
 
134
265
  const sessionKey = buildSessionKey(ctx.accountId, chatType, chatId);
@@ -136,10 +267,11 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
136
267
 
137
268
  // Build inbound context for OpenClaw agent
138
269
  const inboundCtx = core.channel.reply.finalizeInboundContext({
139
- Body: text,
140
- BodyForAgent: text,
141
- RawBody: text,
142
- CommandBody: text,
270
+ Body: body,
271
+ BodyForAgent: body,
272
+ RawBody: body,
273
+ CommandBody: body,
274
+ ...mediaFields,
143
275
  From: `pond:${data.sender_id}`,
144
276
  To: `pond:${chatId}`,
145
277
  SessionKey: sessionKey,
@@ -156,60 +288,42 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
156
288
  OriginatingTo: `pond:${chatId}`,
157
289
  });
158
290
 
159
- let thinkingSent = false;
291
+ await dispatchToAgent({
292
+ core,
293
+ cfg: ctx.cfg,
294
+ client,
295
+ config,
296
+ agentUserId,
297
+ accountId: ctx.accountId,
298
+ chatId,
299
+ messageId: data.id,
300
+ inboundCtx,
301
+ log,
302
+ });
303
+ });
304
+
305
+ // Re-apply platform config when server pushes an update notification
306
+ ws.on("agent_config.update", async (data: AgentConfigUpdateData) => {
307
+ log?.info(`pond[${ctx.accountId}]: config update notification (version=${data.version})`);
160
308
  try {
161
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
162
- ctx: inboundCtx,
163
- cfg: ctx.cfg,
164
- dispatcherOptions: {
165
- deliver: async (payload) => {
166
- const replyText = payload.text?.trim();
167
- if (!replyText) return;
168
- await client.sendMessage(config.org_id, chatId, {
169
- message_type: "text",
170
- content: { text: replyText },
171
- });
172
- },
173
- onReplyStart: () => {
174
- if (thinkingSent) return;
175
- thinkingSent = true;
176
- // Send "thinking" state delta (fire-and-forget)
177
- client.sendMessage(config.org_id, chatId, {
178
- message_type: "state_delta",
179
- content: { state: "thinking", text: "", progress: 0, collapsible: true },
180
- } as SendMessageRequest).catch((err) => {
181
- log?.warn(`pond[${ctx.accountId}]: failed to send thinking state: ${String(err)}`);
182
- });
183
- },
184
- },
185
- });
309
+ await fetchAndApplyPlatformConfig(configManagerOpts);
186
310
  } catch (err) {
187
- log?.error(`pond[${ctx.accountId}]: dispatch failed for ${data.id}: ${String(err)}`);
311
+ log?.warn(`pond[${ctx.accountId}]: config update failed: ${String(err)}`);
188
312
  }
189
313
  });
190
314
 
191
315
  log?.info(`pond[${ctx.accountId}]: connecting to ${wsUrl}...`);
192
316
  await ws.connect();
193
317
 
194
- // Start periodic heartbeat with system telemetry
195
- const heartbeatInterval = setInterval(() => {
196
- if (ws.state !== "connected") return;
197
- const totalMem = os.totalmem();
198
- const freeMem = os.freemem();
199
- ws.sendAgentHeartbeat(sessionId, {
200
- hostname: os.hostname(),
201
- cores: os.cpus().length,
202
- mem_total: totalMem,
203
- mem_free: freeMem,
204
- uptime: os.uptime(),
205
- });
206
- }, 30_000); // every 30s
207
-
208
318
  // Clean up on abort
209
319
  ctx.abortSignal.addEventListener("abort", () => {
210
- clearInterval(heartbeatInterval);
320
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
211
321
  ws.disconnect();
212
322
  removePondAccountState(ctx.accountId);
323
+ // Clear injected env vars so stale credentials don't leak to future subprocesses
324
+ delete process.env.POND_API_URL;
325
+ delete process.env.POND_API_KEY;
326
+ delete process.env.POND_ORG_ID;
213
327
  log?.info(`pond[${ctx.accountId}]: disconnected`);
214
328
  });
215
329
 
package/src/hooks.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import type { SendMessageRequest } from "@pnds/sdk";
3
2
  import { extractAccountIdFromSessionKey } from "./session.js";
4
3
  import { getPondAccountState, getSessionChatId } from "./runtime.js";
5
4
 
6
5
  /**
7
- * Resolve the PondClient + orgId for a hook context.
6
+ * Resolve the PondClient + orgId + runId for a hook context.
8
7
  * Matches by accountId extracted from the session key.
9
8
  */
10
9
  function resolveClientForHook(sessionKey: string | undefined) {
@@ -21,36 +20,59 @@ function resolveClientForHook(sessionKey: string | undefined) {
21
20
  return { ...state, chatId };
22
21
  }
23
22
 
23
+ const POND_CLI_CONTEXT = `## Pond CLI
24
+
25
+ You have access to the Pond platform via the \`@pnds/cli\` CLI. Auth is pre-configured — just run commands directly.
26
+
27
+ \`\`\`
28
+ npx @pnds/cli@latest whoami # Check your identity
29
+ npx @pnds/cli@latest chats list # List chats
30
+ npx @pnds/cli@latest messages list <chatId> # Read chat history
31
+ npx @pnds/cli@latest messages send <chatId> --text "..." # Send a message
32
+ npx @pnds/cli@latest tasks list [--status ...] # List tasks
33
+ npx @pnds/cli@latest tasks create --title "..." # Create a task
34
+ npx @pnds/cli@latest tasks update <taskId> --status in_progress
35
+ npx @pnds/cli@latest projects list # List projects
36
+ npx @pnds/cli@latest users search <query> # Find users
37
+ npx @pnds/cli@latest members list # List org members
38
+ \`\`\`
39
+
40
+ Run \`npx @pnds/cli@latest --help\` or \`npx @pnds/cli@latest <command> --help\` for full options. Output is JSON.`;
41
+
24
42
  export function registerPondHooks(api: OpenClawPluginApi) {
25
43
  const log = api.logger;
26
44
 
27
- // before_tool_call -> send tool_call message to Pond
45
+ // Inject CLI awareness into agent system prompt
46
+ api.on("before_prompt_build", () => {
47
+ return { prependContext: POND_CLI_CONTEXT };
48
+ });
49
+
50
+ // before_tool_call -> send tool_call step to AgentRun
28
51
  api.on("before_tool_call", async (event, ctx) => {
29
52
  const resolved = resolveClientForHook(ctx.sessionKey);
30
- if (!resolved) return;
53
+ if (!resolved || !resolved.activeRunId) return;
31
54
  try {
32
- const req: SendMessageRequest = {
33
- message_type: "tool_call",
55
+ await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
56
+ step_type: "tool_call",
34
57
  content: {
35
58
  tool_name: event.toolName,
36
59
  tool_input: event.params ?? {},
37
60
  status: "running",
38
61
  started_at: new Date().toISOString(),
39
62
  },
40
- };
41
- await resolved.client.sendMessage(resolved.orgId, resolved.chatId, req);
63
+ });
42
64
  } catch (err) {
43
65
  log?.warn(`pond hook before_tool_call failed: ${String(err)}`);
44
66
  }
45
67
  });
46
68
 
47
- // after_tool_call -> send tool_result message to Pond
69
+ // after_tool_call -> send tool_result step to AgentRun
48
70
  api.on("after_tool_call", async (event, ctx) => {
49
71
  const resolved = resolveClientForHook(ctx.sessionKey);
50
- if (!resolved) return;
72
+ if (!resolved || !resolved.activeRunId) return;
51
73
  try {
52
- const req: SendMessageRequest = {
53
- message_type: "tool_result",
74
+ await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
75
+ step_type: "tool_result",
54
76
  content: {
55
77
  tool_call_id: event.toolCallId ?? "",
56
78
  tool_name: event.toolName,
@@ -59,31 +81,29 @@ export function registerPondHooks(api: OpenClawPluginApi) {
59
81
  duration_ms: event.durationMs ?? 0,
60
82
  collapsible: true,
61
83
  },
62
- };
63
- await resolved.client.sendMessage(resolved.orgId, resolved.chatId, req);
84
+ });
64
85
  } catch (err) {
65
86
  log?.warn(`pond hook after_tool_call failed: ${String(err)}`);
66
87
  }
67
88
  });
68
89
 
69
- // agent_end -> send state_delta completed
90
+ // agent_end -> complete the AgentRun
70
91
  api.on("agent_end", async (_event, ctx) => {
71
92
  const resolved = resolveClientForHook(ctx.sessionKey);
72
93
  if (!resolved) {
73
94
  log?.warn(`pond hook agent_end: could not resolve client (sessionKey=${ctx.sessionKey})`);
74
95
  return;
75
96
  }
97
+ if (!resolved.activeRunId) return;
76
98
  try {
77
- const req: SendMessageRequest = {
78
- message_type: "state_delta",
79
- content: {
80
- state: "completed",
81
- text: "",
82
- progress: 100,
83
- collapsible: true,
84
- },
85
- };
86
- await resolved.client.sendMessage(resolved.orgId, resolved.chatId, req);
99
+ await resolved.client.updateAgentRun(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
100
+ status: "completed",
101
+ });
102
+ // Clear the active run
103
+ const state = getPondAccountState(
104
+ extractAccountIdFromSessionKey(ctx.sessionKey ?? "") ?? "",
105
+ );
106
+ if (state) state.activeRunId = undefined;
87
107
  } catch (err) {
88
108
  log?.warn(`pond hook agent_end failed: ${String(err)}`);
89
109
  }
package/src/runtime.ts CHANGED
@@ -19,6 +19,7 @@ export type PondAccountState = {
19
19
  client: PondClient;
20
20
  orgId: string;
21
21
  agentUserId: string;
22
+ activeRunId?: string; // current AgentRun ID, set by gateway on reply start
22
23
  };
23
24
 
24
25
  const accountStates = new Map<string, PondAccountState>();