@pnds/pond 0.1.1 → 1.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnds/pond",
3
- "version": "0.1.1",
3
+ "version": "1.0.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.1"
17
+ "@pnds/sdk": "1.0.0"
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,71 @@ 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
+ let runIdPromise: Promise<string | undefined> | undefined;
60
+ try {
61
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
62
+ ctx: inboundCtx,
63
+ cfg,
64
+ dispatcherOptions: {
65
+ deliver: async (payload: { text?: string }) => {
66
+ const replyText = payload.text?.trim();
67
+ if (!replyText) return;
68
+ // Wait for run creation to complete before sending
69
+ const runId = runIdPromise ? await runIdPromise : undefined;
70
+ await client.sendMessage(config.org_id, chatId, {
71
+ message_type: "text",
72
+ content: { text: replyText },
73
+ agent_run_id: runId,
74
+ });
75
+ },
76
+ onReplyStart: () => {
77
+ if (thinkingSent) return;
78
+ thinkingSent = true;
79
+ runIdPromise = (async () => {
80
+ try {
81
+ const run = await client.createAgentRun(config.org_id, agentUserId, {
82
+ trigger_type: "mention",
83
+ trigger_ref: { message_id: messageId },
84
+ chat_id: chatId,
85
+ });
86
+ const state = getPondAccountState(accountId);
87
+ if (state) state.activeRunId = run.id;
88
+ await client.createAgentStep(config.org_id, agentUserId, run.id, {
89
+ step_type: "thinking",
90
+ content: { text: "" },
91
+ });
92
+ return run.id;
93
+ } catch (err) {
94
+ log?.warn(`pond[${accountId}]: failed to create agent run: ${String(err)}`);
95
+ return undefined;
96
+ }
97
+ })();
98
+ },
99
+ },
100
+ });
101
+ } catch (err) {
102
+ log?.error(`pond[${accountId}]: dispatch failed for ${messageId}: ${String(err)}`);
103
+ }
104
+ }
105
+
38
106
  export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
39
107
  startAccount: async (ctx) => {
40
108
  const account = resolvePondAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
@@ -55,6 +123,30 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
55
123
  const agentUserId = me.id;
56
124
  log?.info(`pond[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
57
125
 
126
+ // Inject env vars so child processes (e.g. @pnds/cli) inherit Pond credentials
127
+ process.env.POND_API_URL = config.pond_url;
128
+ process.env.POND_API_KEY = config.api_key;
129
+ process.env.POND_ORG_ID = config.org_id;
130
+
131
+ // Apply platform config (models, defaults)
132
+ // Use OPENCLAW_STATE_DIR if set, otherwise derive from HOME (systemd sets HOME=/data for hosted agents)
133
+ const stateDir = process.env.OPENCLAW_STATE_DIR
134
+ || path.join(process.env.HOME || "/data", ".openclaw");
135
+ const openclawConfigPath = path.join(stateDir, "openclaw.json");
136
+ const configManagerOpts = {
137
+ client,
138
+ stateDir,
139
+ configPath: openclawConfigPath,
140
+ credentials: { api_key: config.api_key, pond_url: config.pond_url },
141
+ log,
142
+ };
143
+
144
+ try {
145
+ await fetchAndApplyPlatformConfig(configManagerOpts);
146
+ } catch (err) {
147
+ log?.warn(`pond[${ctx.accountId}]: platform config fetch failed: ${String(err)}`);
148
+ }
149
+
58
150
  // Connect WebSocket via ticket auth (reconnection enabled by default in PondWs)
59
151
  const wsUrl = resolveWsUrl(account);
60
152
  const ws = new PondWs({
@@ -67,15 +159,18 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
67
159
 
68
160
  // Session ID from hello — used for heartbeat
69
161
  let sessionId = "";
162
+ // Heartbeat interval handle — created inside hello handler with server-provided interval
163
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
70
164
 
71
165
  // Log connection state changes (covers reconnection)
72
166
  ws.onStateChange((state) => {
73
167
  log?.info(`pond[${ctx.accountId}]: connection state → ${state}`);
74
168
  });
75
169
 
76
- // On hello, cache chat types (with pagination) and start heartbeat
170
+ // On hello, cache chat types and start heartbeat with server-provided interval
77
171
  ws.on("hello", async (data: HelloData) => {
78
172
  sessionId = data.session_id ?? "";
173
+ const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
79
174
  try {
80
175
  const count = await fetchAllChats(client, config.org_id, chatTypeMap);
81
176
  log?.info(`pond[${ctx.accountId}]: WebSocket connected, ${count} chats cached`);
@@ -86,6 +181,19 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
86
181
  orgId: config.org_id,
87
182
  agentUserId,
88
183
  });
184
+
185
+ // (Re)start heartbeat with server-provided interval
186
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
187
+ heartbeatTimer = setInterval(() => {
188
+ if (ws.state !== "connected") return;
189
+ ws.sendAgentHeartbeat(sessionId, {
190
+ hostname: os.hostname(),
191
+ cores: os.cpus().length,
192
+ mem_total: os.totalmem(),
193
+ mem_free: os.freemem(),
194
+ uptime: os.uptime(),
195
+ });
196
+ }, intervalSec * 1000);
89
197
  } catch (err) {
90
198
  log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
91
199
  }
@@ -98,14 +206,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
98
206
  }
99
207
  });
100
208
 
101
- // Handle inbound messages
209
+ // Handle inbound messages (text + file)
102
210
  ws.on("message.new", async (data: MessageNewData) => {
103
211
  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;
212
+ if (data.message_type !== "text" && data.message_type !== "file") return;
109
213
 
110
214
  const chatId = data.chat_id;
111
215
 
@@ -122,13 +226,46 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
122
226
  }
123
227
  }
124
228
 
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;
229
+ // Build body text and optional media fields based on message type
230
+ let body = "";
231
+ let mediaFields: Record<string, string | undefined> = {};
232
+
233
+ if (data.message_type === "text") {
234
+ const content = data.content as TextContent;
235
+ body = content.text?.trim() ?? "";
236
+ if (!body) return;
237
+
238
+ // In group chats, only respond when @mentioned or @all
239
+ if (chatType === "group") {
240
+ const mentions = content.mentions ?? [];
241
+ const isMentioned = mentions.some(
242
+ (m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID
243
+ );
244
+ if (!isMentioned) return;
245
+ }
246
+ } else {
247
+ // file message
248
+ const content = data.content as MediaContent;
249
+
250
+ // In group chats, file messages have no mentions field — skip
251
+ if (chatType === "group") return;
252
+
253
+ body = content.caption?.trim()
254
+ || `[file: ${content.file_name || "attachment"}]`;
255
+
256
+ // Resolve presigned download URL for the attachment
257
+ if (content.attachment_id) {
258
+ try {
259
+ const fileRes = await client.getFileUrl(content.attachment_id);
260
+ mediaFields = {
261
+ MediaUrl: fileRes.url,
262
+ MediaType: content.mime_type,
263
+ };
264
+ } catch (err) {
265
+ log?.warn(`pond[${ctx.accountId}]: failed to get file URL for ${content.attachment_id}: ${String(err)}`);
266
+ // Degrade gracefully — agent still gets the body text
267
+ }
268
+ }
132
269
  }
133
270
 
134
271
  const sessionKey = buildSessionKey(ctx.accountId, chatType, chatId);
@@ -136,10 +273,11 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
136
273
 
137
274
  // Build inbound context for OpenClaw agent
138
275
  const inboundCtx = core.channel.reply.finalizeInboundContext({
139
- Body: text,
140
- BodyForAgent: text,
141
- RawBody: text,
142
- CommandBody: text,
276
+ Body: body,
277
+ BodyForAgent: body,
278
+ RawBody: body,
279
+ CommandBody: body,
280
+ ...mediaFields,
143
281
  From: `pond:${data.sender_id}`,
144
282
  To: `pond:${chatId}`,
145
283
  SessionKey: sessionKey,
@@ -156,60 +294,42 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
156
294
  OriginatingTo: `pond:${chatId}`,
157
295
  });
158
296
 
159
- let thinkingSent = false;
297
+ await dispatchToAgent({
298
+ core,
299
+ cfg: ctx.cfg,
300
+ client,
301
+ config,
302
+ agentUserId,
303
+ accountId: ctx.accountId,
304
+ chatId,
305
+ messageId: data.id,
306
+ inboundCtx,
307
+ log,
308
+ });
309
+ });
310
+
311
+ // Re-apply platform config when server pushes an update notification
312
+ ws.on("agent_config.update", async (data: AgentConfigUpdateData) => {
313
+ log?.info(`pond[${ctx.accountId}]: config update notification (version=${data.version})`);
160
314
  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
- });
315
+ await fetchAndApplyPlatformConfig(configManagerOpts);
186
316
  } catch (err) {
187
- log?.error(`pond[${ctx.accountId}]: dispatch failed for ${data.id}: ${String(err)}`);
317
+ log?.warn(`pond[${ctx.accountId}]: config update failed: ${String(err)}`);
188
318
  }
189
319
  });
190
320
 
191
321
  log?.info(`pond[${ctx.accountId}]: connecting to ${wsUrl}...`);
192
322
  await ws.connect();
193
323
 
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
324
  // Clean up on abort
209
325
  ctx.abortSignal.addEventListener("abort", () => {
210
- clearInterval(heartbeatInterval);
326
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
211
327
  ws.disconnect();
212
328
  removePondAccountState(ctx.accountId);
329
+ // Clear injected env vars so stale credentials don't leak to future subprocesses
330
+ delete process.env.POND_API_URL;
331
+ delete process.env.POND_API_KEY;
332
+ delete process.env.POND_ORG_ID;
213
333
  log?.info(`pond[${ctx.accountId}]: disconnected`);
214
334
  });
215
335
 
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/outbound.ts CHANGED
@@ -25,6 +25,7 @@ export const pondOutbound: ChannelOutboundAdapter = {
25
25
  const req: SendMessageRequest = {
26
26
  message_type: "text",
27
27
  content: { text },
28
+ agent_run_id: state?.activeRunId,
28
29
  };
29
30
  const msg = await client.sendMessage(orgId, chatId, req);
30
31
  return { channel: "pond", messageId: msg.id, channelId: chatId };
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>();