@pnds/pond 1.1.0 → 1.2.1
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/TOOLS.md +44 -0
- package/package.json +17 -4
- package/src/action-tools.ts +256 -0
- package/src/channel.ts +2 -2
- package/src/config-manager.ts +18 -0
- package/src/fork.ts +213 -0
- package/src/gateway.ts +717 -222
- package/src/hooks.ts +61 -25
- package/src/index.ts +5 -1
- package/src/outbound.ts +4 -1
- package/src/routing.ts +48 -0
- package/src/runtime.ts +84 -1
- package/src/session.ts +5 -0
- package/src/tool-helpers.ts +11 -0
- package/src/tools-md.ts +7 -0
- package/src/wiki-helper.ts +157 -0
- package/src/wiki-tools.ts +370 -0
package/TOOLS.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Pond Tools
|
|
2
|
+
|
|
3
|
+
## Pond Wiki Tools
|
|
4
|
+
|
|
5
|
+
Use native Pond wiki tools for wiki lookup and editing instead of shelling out to the CLI.
|
|
6
|
+
|
|
7
|
+
- `wiki_list` to find candidate wikis
|
|
8
|
+
- `wiki_query` as the primary tree-aware retrieval tool
|
|
9
|
+
- `wiki_outline` to inspect the local structural outline of a wiki or path prefix
|
|
10
|
+
- `wiki_search` as a simpler lexical fallback
|
|
11
|
+
- `wiki_section` to expand a result by `nodeId`
|
|
12
|
+
- `wiki_blob` only when you need the whole file
|
|
13
|
+
- `wiki_status` to inspect local mounted wiki edits
|
|
14
|
+
- `wiki_diff` to review the local mounted wiki patch
|
|
15
|
+
- `wiki_propose` to open or update a wiki changeset after editing
|
|
16
|
+
- `wiki_changeset_diff` to inspect a wiki draft or changeset
|
|
17
|
+
|
|
18
|
+
Typical wiki edit flow:
|
|
19
|
+
|
|
20
|
+
1. `wiki_query` or `wiki_outline` to locate the right content
|
|
21
|
+
2. `wiki_section` to read the exact section body you need
|
|
22
|
+
3. Edit the mounted wiki files locally
|
|
23
|
+
4. `wiki_status` and `wiki_diff` to verify the exact change
|
|
24
|
+
5. `wiki_propose` to submit or update the wiki changeset
|
|
25
|
+
|
|
26
|
+
## Pond CLI (read-only)
|
|
27
|
+
|
|
28
|
+
You have read-only access to the Pond platform via the `@pnds/cli` CLI. Auth is pre-configured.
|
|
29
|
+
|
|
30
|
+
> **DO NOT use the CLI to send messages.** Use `pond_reply` for chat replies and `pond_dm` for direct messages.
|
|
31
|
+
> **DO NOT use the CLI to create tasks.** Use task tools or sub-agents instead.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx @pnds/cli@latest whoami # Check your identity
|
|
35
|
+
npx @pnds/cli@latest agents list # List all agents
|
|
36
|
+
npx @pnds/cli@latest chats list # List chats
|
|
37
|
+
npx @pnds/cli@latest messages list <chatId> # Read chat history
|
|
38
|
+
npx @pnds/cli@latest tasks list [--status ...] # List tasks
|
|
39
|
+
npx @pnds/cli@latest projects list # List projects
|
|
40
|
+
npx @pnds/cli@latest users search <query> # Find users
|
|
41
|
+
npx @pnds/cli@latest members list # List org members
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run `npx @pnds/cli@latest --help` or `npx @pnds/cli@latest <command> --help` for full options. Output is JSON.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pnds/pond",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "OpenClaw channel plugin for Pond IM",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -11,20 +11,33 @@
|
|
|
11
11
|
"type": "module",
|
|
12
12
|
"files": [
|
|
13
13
|
"src",
|
|
14
|
+
"TOOLS.md",
|
|
14
15
|
"openclaw.plugin.json"
|
|
15
16
|
],
|
|
16
17
|
"dependencies": {
|
|
17
|
-
"@
|
|
18
|
+
"@sinclair/typebox": "^0.34.48",
|
|
19
|
+
"@pnds/sdk": "1.2.1",
|
|
20
|
+
"@pnds/cli": "1.2.1"
|
|
18
21
|
},
|
|
19
22
|
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.0.0",
|
|
24
|
+
"openclaw": "2026.3.24",
|
|
20
25
|
"typescript": "^5.7.0"
|
|
21
26
|
},
|
|
22
27
|
"peerDependencies": {
|
|
23
|
-
"
|
|
28
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
29
|
+
"openclaw": ">=2026.3.24"
|
|
24
30
|
},
|
|
25
31
|
"openclaw": {
|
|
26
32
|
"extensions": [
|
|
27
33
|
"./src/index.ts"
|
|
28
|
-
]
|
|
34
|
+
],
|
|
35
|
+
"install": {
|
|
36
|
+
"minHostVersion": ">=2026.3.24"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -b",
|
|
41
|
+
"test": "node --test test/*.test.mjs"
|
|
29
42
|
}
|
|
30
43
|
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { OpenClawPluginApi, AnyAgentTool } from "openclaw/plugin-sdk";
|
|
3
|
+
import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
|
|
4
|
+
import { jsonResult } from "./tool-helpers.js";
|
|
5
|
+
import { PondClient } from "@pnds/sdk";
|
|
6
|
+
import type { Message } from "@pnds/sdk";
|
|
7
|
+
import {
|
|
8
|
+
getActiveRunId,
|
|
9
|
+
getAllPondAccountStates,
|
|
10
|
+
getPondAccountState,
|
|
11
|
+
getDispatchNoReply,
|
|
12
|
+
} from "./runtime.js";
|
|
13
|
+
import { extractAccountIdFromSessionKey } from "./session.js";
|
|
14
|
+
|
|
15
|
+
type ToolSessionContext = {
|
|
16
|
+
sessionKey?: string;
|
|
17
|
+
agentAccountId?: string;
|
|
18
|
+
agentId?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function resolvePondState(ctx: ToolSessionContext) {
|
|
22
|
+
const accountId = ctx.agentAccountId ?? extractAccountIdFromSessionKey(ctx.sessionKey);
|
|
23
|
+
if (accountId) {
|
|
24
|
+
const state = getPondAccountState(accountId);
|
|
25
|
+
if (state) return state;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const states = Array.from(getAllPondAccountStates().values());
|
|
29
|
+
if (states.length === 1) return states[0];
|
|
30
|
+
|
|
31
|
+
const baseUrl = process.env.POND_API_URL?.trim();
|
|
32
|
+
const token = process.env.POND_API_KEY?.trim();
|
|
33
|
+
const orgId = process.env.POND_ORG_ID?.trim();
|
|
34
|
+
if (baseUrl && token && orgId) {
|
|
35
|
+
return {
|
|
36
|
+
client: new PondClient({ baseUrl, token }),
|
|
37
|
+
orgId,
|
|
38
|
+
agentUserId: "",
|
|
39
|
+
activeRuns: new Map<string, Promise<string | undefined>>(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
throw new Error(
|
|
44
|
+
"Pond action tools are unavailable: missing live Pond runtime state and POND_API_URL/POND_API_KEY/POND_ORG_ID.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- Schemas ----
|
|
49
|
+
|
|
50
|
+
const ReplySchema = Type.Object({
|
|
51
|
+
chatId: Type.String({ minLength: 1, description: "Chat ID to send the message to." }),
|
|
52
|
+
text: Type.String({ minLength: 1, description: "Message text." }),
|
|
53
|
+
threadRootId: Type.Optional(Type.String({ description: "If provided, send the message as a thread reply under this root message ID." })),
|
|
54
|
+
noReply: Type.Optional(Type.Boolean({ description: "If true, hints that no reply is expected." })),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const TaskCommentSchema = Type.Object({
|
|
58
|
+
taskId: Type.String({ minLength: 1, description: "Task ID to comment on." }),
|
|
59
|
+
body: Type.String({ minLength: 1, description: "Comment body text." }),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const TaskUpdateSchema = Type.Object({
|
|
63
|
+
taskId: Type.String({ minLength: 1, description: "Task ID to update." }),
|
|
64
|
+
status: Type.Optional(Type.String({ description: "New task status." })),
|
|
65
|
+
title: Type.Optional(Type.String({ description: "New task title." })),
|
|
66
|
+
description: Type.Optional(Type.String({ description: "New task description." })),
|
|
67
|
+
priority: Type.Optional(Type.String({ description: "New task priority." })),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const ChatHistorySchema = Type.Object({
|
|
71
|
+
chatId: Type.String({ minLength: 1, description: "Chat ID to read messages from." }),
|
|
72
|
+
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50, description: "Number of messages to fetch (default 20, max 50)." })),
|
|
73
|
+
before: Type.Optional(Type.String({ description: "Message ID cursor — fetch messages before this ID." })),
|
|
74
|
+
threadRootId: Type.Optional(Type.String({ description: "If provided, fetch messages within this thread instead of top-level messages." })),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const TypingSchema = Type.Object({
|
|
78
|
+
chatId: Type.String({ minLength: 1, description: "Chat ID for the typing indicator." }),
|
|
79
|
+
action: Type.Union([Type.Literal("start"), Type.Literal("stop")], { description: "Start or stop the typing indicator." }),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const DmSchema = Type.Object({
|
|
83
|
+
userId: Type.String({ minLength: 1, description: "Target user ID to DM." }),
|
|
84
|
+
text: Type.String({ minLength: 1, description: "Message text." }),
|
|
85
|
+
noReply: Type.Optional(Type.Boolean({ description: "If true, hints that no reply is expected." })),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---- Helpers ----
|
|
89
|
+
|
|
90
|
+
function formatMessage(msg: Message): string {
|
|
91
|
+
const senderName = msg.sender?.display_name ?? msg.sender_id;
|
|
92
|
+
const content = msg.content as Record<string, unknown>;
|
|
93
|
+
if (msg.message_type === "text") {
|
|
94
|
+
return `${senderName} (${msg.sender_id}): ${(content as { text?: string }).text ?? ""}`;
|
|
95
|
+
}
|
|
96
|
+
if (msg.message_type === "file") {
|
|
97
|
+
return `${senderName} (${msg.sender_id}): [file: ${(content as { file_name?: string }).file_name ?? "unknown"}]`;
|
|
98
|
+
}
|
|
99
|
+
if (msg.message_type === "system") {
|
|
100
|
+
return `[system: ${(content as { text?: string }).text ?? JSON.stringify(content)}]`;
|
|
101
|
+
}
|
|
102
|
+
return `${senderName} (${msg.sender_id}): [${msg.message_type}]`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- Tool factory ----
|
|
106
|
+
|
|
107
|
+
function createActionTools(toolCtx: ToolSessionContext): AnyAgentTool[] {
|
|
108
|
+
const state = () => resolvePondState(toolCtx);
|
|
109
|
+
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
label: "Pond Reply",
|
|
113
|
+
name: "pond_reply",
|
|
114
|
+
description: "Send a text message to a Pond chat.",
|
|
115
|
+
parameters: ReplySchema,
|
|
116
|
+
execute: async (_toolCallId, params) => {
|
|
117
|
+
const s = state();
|
|
118
|
+
const chatId = readStringParam(params, "chatId", { required: true });
|
|
119
|
+
const text = readStringParam(params, "text", { required: true });
|
|
120
|
+
const threadRootId = readStringParam(params, "threadRootId");
|
|
121
|
+
const noReply = params.noReply === true;
|
|
122
|
+
const runId = s.activeRuns && toolCtx.sessionKey
|
|
123
|
+
? await getActiveRunId(s, toolCtx.sessionKey)
|
|
124
|
+
: undefined;
|
|
125
|
+
|
|
126
|
+
// Deterministic no_reply suppression: if the inbound event had no_reply hint,
|
|
127
|
+
// hard-block the send and record a suppressed step instead of posting to chat.
|
|
128
|
+
// This prevents agent-to-agent reply loops at the plugin layer (not just prompt).
|
|
129
|
+
if (toolCtx.sessionKey && getDispatchNoReply(toolCtx.sessionKey)) {
|
|
130
|
+
if (runId) {
|
|
131
|
+
try {
|
|
132
|
+
await s.client.createAgentStep(s.orgId, s.agentUserId, runId, {
|
|
133
|
+
step_type: "text",
|
|
134
|
+
content: { text, suppressed: true, reason: "no_reply" },
|
|
135
|
+
});
|
|
136
|
+
} catch { /* best-effort step recording */ }
|
|
137
|
+
}
|
|
138
|
+
return jsonResult({ suppressed: true, reason: "no_reply", chat_id: chatId });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = await s.client.sendMessage(s.orgId, chatId, {
|
|
142
|
+
message_type: "text",
|
|
143
|
+
content: { text },
|
|
144
|
+
agent_run_id: runId,
|
|
145
|
+
...(threadRootId ? { thread_root_id: threadRootId } : {}),
|
|
146
|
+
...(noReply ? { hints: { no_reply: true } } : {}),
|
|
147
|
+
});
|
|
148
|
+
return jsonResult({ message_id: result.id, chat_id: chatId });
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
label: "Pond Task Comment",
|
|
153
|
+
name: "pond_task_comment",
|
|
154
|
+
description: "Post a comment on a Pond task.",
|
|
155
|
+
parameters: TaskCommentSchema,
|
|
156
|
+
execute: async (_toolCallId, params) => {
|
|
157
|
+
const s = state();
|
|
158
|
+
const taskId = readStringParam(params, "taskId", { required: true });
|
|
159
|
+
const body = readStringParam(params, "body", { required: true });
|
|
160
|
+
const result = await s.client.createTaskComment(s.orgId, taskId, { body });
|
|
161
|
+
return jsonResult(result);
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
label: "Pond Task Update",
|
|
166
|
+
name: "pond_task_update",
|
|
167
|
+
description: "Update fields on a Pond task (status, title, description, priority).",
|
|
168
|
+
parameters: TaskUpdateSchema,
|
|
169
|
+
execute: async (_toolCallId, params) => {
|
|
170
|
+
const s = state();
|
|
171
|
+
const taskId = readStringParam(params, "taskId", { required: true });
|
|
172
|
+
const update: Record<string, string> = {};
|
|
173
|
+
for (const field of ["status", "title", "description", "priority"] as const) {
|
|
174
|
+
const val = readStringParam(params, field);
|
|
175
|
+
if (val !== undefined) update[field] = val;
|
|
176
|
+
}
|
|
177
|
+
const result = await s.client.updateTask(s.orgId, taskId, update);
|
|
178
|
+
return jsonResult(result);
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
label: "Pond Chat History",
|
|
183
|
+
name: "pond_chat_history",
|
|
184
|
+
description: "Read recent messages from a Pond chat. Use this to get context on unfamiliar conversations.",
|
|
185
|
+
parameters: ChatHistorySchema,
|
|
186
|
+
execute: async (_toolCallId, params) => {
|
|
187
|
+
const s = state();
|
|
188
|
+
const chatId = readStringParam(params, "chatId", { required: true });
|
|
189
|
+
const limit = Math.min(readNumberParam(params, "limit", { integer: true }) ?? 20, 50);
|
|
190
|
+
const before = readStringParam(params, "before");
|
|
191
|
+
const threadRootId = readStringParam(params, "threadRootId");
|
|
192
|
+
const threadParams = threadRootId
|
|
193
|
+
? { thread_root_id: threadRootId }
|
|
194
|
+
: { top_level: true as const };
|
|
195
|
+
const result = await s.client.getMessages(s.orgId, chatId, {
|
|
196
|
+
limit,
|
|
197
|
+
before,
|
|
198
|
+
...threadParams,
|
|
199
|
+
});
|
|
200
|
+
const messages = result.data.map(formatMessage);
|
|
201
|
+
return jsonResult({
|
|
202
|
+
messages,
|
|
203
|
+
has_more: result.has_more,
|
|
204
|
+
...(result.next_cursor ? { next_cursor: result.next_cursor } : {}),
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
label: "Pond Typing",
|
|
210
|
+
name: "pond_typing",
|
|
211
|
+
description: "Start or stop the typing indicator in a Pond chat.",
|
|
212
|
+
parameters: TypingSchema,
|
|
213
|
+
execute: async (_toolCallId, params) => {
|
|
214
|
+
const s = state();
|
|
215
|
+
if (!s.ws) {
|
|
216
|
+
return jsonResult({ error: "WebSocket not available — typing indicator requires a live connection." });
|
|
217
|
+
}
|
|
218
|
+
const chatId = readStringParam(params, "chatId", { required: true });
|
|
219
|
+
const action = readStringParam(params, "action", { required: true }) as "start" | "stop";
|
|
220
|
+
s.ws.sendTyping(chatId, action);
|
|
221
|
+
return jsonResult({ ok: true });
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
label: "Pond DM",
|
|
226
|
+
name: "pond_dm",
|
|
227
|
+
description: "Send a direct message to a user by ID. Automatically finds or creates the DM chat.",
|
|
228
|
+
parameters: DmSchema,
|
|
229
|
+
execute: async (_toolCallId, params) => {
|
|
230
|
+
const s = state();
|
|
231
|
+
const userId = readStringParam(params, "userId", { required: true });
|
|
232
|
+
const text = readStringParam(params, "text", { required: true });
|
|
233
|
+
const noReply = params.noReply === true;
|
|
234
|
+
const runId = s.activeRuns && toolCtx.sessionKey
|
|
235
|
+
? await getActiveRunId(s, toolCtx.sessionKey)
|
|
236
|
+
: undefined;
|
|
237
|
+
|
|
238
|
+
const result = await s.client.sendDirectMessage(s.orgId, {
|
|
239
|
+
user_id: userId,
|
|
240
|
+
message_type: "text",
|
|
241
|
+
content: { text },
|
|
242
|
+
agent_run_id: runId,
|
|
243
|
+
...(noReply ? { hints: { no_reply: true } } : {}),
|
|
244
|
+
});
|
|
245
|
+
return jsonResult({ chat_id: result.chat.id, message_id: result.message.id });
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function registerPondActionTools(api: OpenClawPluginApi) {
|
|
252
|
+
api.registerTool(
|
|
253
|
+
(ctx) => createActionTools(ctx),
|
|
254
|
+
{ names: ["pond_reply", "pond_task_comment", "pond_task_update", "pond_chat_history", "pond_typing", "pond_dm"] },
|
|
255
|
+
);
|
|
256
|
+
}
|
package/src/channel.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { ChannelPlugin
|
|
1
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
2
2
|
import { listPondAccountIds, resolvePondAccount } from "./accounts.js";
|
|
3
3
|
import { pondGateway } from "./gateway.js";
|
|
4
4
|
import { pondOutbound } from "./outbound.js";
|
|
5
5
|
import type { ResolvedPondAccount } from "./types.js";
|
|
6
6
|
|
|
7
|
-
const meta:
|
|
7
|
+
const meta: ChannelPlugin["meta"] = {
|
|
8
8
|
id: "pond",
|
|
9
9
|
label: "Pond",
|
|
10
10
|
selectionLabel: "Pond IM",
|
package/src/config-manager.ts
CHANGED
|
@@ -19,6 +19,13 @@ interface ConfigManagerOpts {
|
|
|
19
19
|
|
|
20
20
|
const CACHE_FILENAME = "pond-platform-config.json";
|
|
21
21
|
|
|
22
|
+
/** All tool names registered by this plugin — must stay in sync with registerPondActionTools + registerPondWikiTools. */
|
|
23
|
+
const POND_TOOL_NAMES = [
|
|
24
|
+
"pond_reply", "pond_dm", "pond_task_comment", "pond_task_update", "pond_chat_history", "pond_typing",
|
|
25
|
+
"wiki_list", "wiki_status", "wiki_diff", "wiki_tree", "wiki_blob",
|
|
26
|
+
"wiki_search", "wiki_query", "wiki_outline", "wiki_section", "wiki_changeset_diff", "wiki_propose",
|
|
27
|
+
];
|
|
28
|
+
|
|
22
29
|
function cachePath(stateDir: string): string {
|
|
23
30
|
return path.join(stateDir, CACHE_FILENAME);
|
|
24
31
|
}
|
|
@@ -91,6 +98,17 @@ function applyToOpenClawConfig(
|
|
|
91
98
|
existing.agents = agents;
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
// Ensure Pond plugin tools are allowed alongside whatever tools.profile is set.
|
|
102
|
+
// Uses `alsoAllow` (additive) so user's profile choice is preserved.
|
|
103
|
+
const tools =
|
|
104
|
+
typeof existing.tools === "object"
|
|
105
|
+
&& existing.tools !== null
|
|
106
|
+
&& !Array.isArray(existing.tools)
|
|
107
|
+
? (existing.tools as Record<string, unknown>)
|
|
108
|
+
: {};
|
|
109
|
+
tools.alsoAllow = POND_TOOL_NAMES;
|
|
110
|
+
existing.tools = tools;
|
|
111
|
+
|
|
94
112
|
// Atomic write
|
|
95
113
|
const tmpPath = `${configPath}.tmp`;
|
|
96
114
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
package/src/fork.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// fork.ts — Fork-on-busy engine for orchestrator mode
|
|
2
|
+
//
|
|
3
|
+
// When the orchestrator's main session is busy, new events are handled by
|
|
4
|
+
// forking the main session's transcript and dispatching to the fork in parallel.
|
|
5
|
+
// The on-disk JSONL transcript always reflects the state BEFORE the current
|
|
6
|
+
// dispatch (in-flight messages are memory-only), so the fork inherits all
|
|
7
|
+
// prior history without race conditions.
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import * as crypto from "node:crypto";
|
|
12
|
+
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
export type ForkSessionResult = {
|
|
15
|
+
sessionKey: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
sessionFile: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Session store helpers — read sessions.json directly (SSOT for file paths)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
type SessionStoreEntry = {
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
sessionFile?: string;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read sessions.json and find the entry for a given session key.
|
|
32
|
+
* OpenClaw may store keys in lowercase; we try both.
|
|
33
|
+
*/
|
|
34
|
+
function readStoreEntry(sessionsDir: string, sessionKey: string): SessionStoreEntry | null {
|
|
35
|
+
const storeFile = path.join(sessionsDir, "sessions.json");
|
|
36
|
+
try {
|
|
37
|
+
const store = JSON.parse(fs.readFileSync(storeFile, "utf-8")) as Record<string, SessionStoreEntry>;
|
|
38
|
+
return store[sessionKey] ?? store[sessionKey.toLowerCase()] ?? null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write or update an entry in sessions.json.
|
|
46
|
+
*/
|
|
47
|
+
function writeStoreEntry(sessionsDir: string, sessionKey: string, entry: SessionStoreEntry): boolean {
|
|
48
|
+
const storeFile = path.join(sessionsDir, "sessions.json");
|
|
49
|
+
try {
|
|
50
|
+
let store: Record<string, SessionStoreEntry> = {};
|
|
51
|
+
try {
|
|
52
|
+
store = JSON.parse(fs.readFileSync(storeFile, "utf-8"));
|
|
53
|
+
} catch { /* empty or missing — start fresh */ }
|
|
54
|
+
store[sessionKey.toLowerCase()] = entry;
|
|
55
|
+
fs.writeFileSync(storeFile, JSON.stringify(store, null, 2), { encoding: "utf-8" });
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Delete an entry from sessions.json.
|
|
64
|
+
*/
|
|
65
|
+
function deleteStoreEntry(sessionsDir: string, sessionKey: string): void {
|
|
66
|
+
const storeFile = path.join(sessionsDir, "sessions.json");
|
|
67
|
+
try {
|
|
68
|
+
const store = JSON.parse(fs.readFileSync(storeFile, "utf-8")) as Record<string, unknown>;
|
|
69
|
+
delete store[sessionKey];
|
|
70
|
+
delete store[sessionKey.toLowerCase()];
|
|
71
|
+
fs.writeFileSync(storeFile, JSON.stringify(store, null, 2), { encoding: "utf-8" });
|
|
72
|
+
} catch {
|
|
73
|
+
// Best-effort
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Transcript file resolution (via session store)
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the transcript file for a session key by reading the session store.
|
|
83
|
+
* Returns the absolute path, or null if not found.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveTranscriptFile(sessionsDir: string, sessionKey: string): string | null {
|
|
86
|
+
const entry = readStoreEntry(sessionsDir, sessionKey);
|
|
87
|
+
if (!entry?.sessionId) return null;
|
|
88
|
+
|
|
89
|
+
// Prefer explicit sessionFile field
|
|
90
|
+
if (entry.sessionFile) {
|
|
91
|
+
const resolved = path.isAbsolute(entry.sessionFile)
|
|
92
|
+
? entry.sessionFile
|
|
93
|
+
: path.join(sessionsDir, entry.sessionFile);
|
|
94
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fallback: conventional {sessionId}.jsonl
|
|
98
|
+
const conventional = path.join(sessionsDir, `${entry.sessionId}.jsonl`);
|
|
99
|
+
if (fs.existsSync(conventional)) return conventional;
|
|
100
|
+
|
|
101
|
+
// Last resort: scan for files containing the sessionId
|
|
102
|
+
try {
|
|
103
|
+
const files = fs.readdirSync(sessionsDir);
|
|
104
|
+
const match = files.find((f) => f.includes(entry.sessionId!) && f.endsWith(".jsonl"));
|
|
105
|
+
return match ? path.join(sessionsDir, match) : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Fork creation
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Fork the orchestrator session's transcript into a new parallel session.
|
|
117
|
+
*
|
|
118
|
+
* The fork inherits all history up to the current dispatch start (on-disk state).
|
|
119
|
+
* It gets its own session key, write lock, and can dispatch independently.
|
|
120
|
+
*
|
|
121
|
+
* Creates a session store entry with lineage metadata (spawnedBy).
|
|
122
|
+
*/
|
|
123
|
+
export function forkOrchestratorSession(opts: {
|
|
124
|
+
orchestratorSessionKey: string;
|
|
125
|
+
accountId: string;
|
|
126
|
+
transcriptFile: string;
|
|
127
|
+
sessionsDir: string;
|
|
128
|
+
}): ForkSessionResult | null {
|
|
129
|
+
const { orchestratorSessionKey, accountId, transcriptFile, sessionsDir } = opts;
|
|
130
|
+
|
|
131
|
+
if (!fs.existsSync(transcriptFile)) return null;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const manager = SessionManager.open(transcriptFile);
|
|
135
|
+
const leafId = manager.getLeafId();
|
|
136
|
+
|
|
137
|
+
let sessionId: string;
|
|
138
|
+
let sessionFile: string;
|
|
139
|
+
|
|
140
|
+
if (leafId) {
|
|
141
|
+
// Branch from the current leaf — inherits full transcript history
|
|
142
|
+
const branched = manager.createBranchedSession(leafId) ?? manager.getSessionFile();
|
|
143
|
+
const branchedId = manager.getSessionId();
|
|
144
|
+
if (branched && branchedId) {
|
|
145
|
+
sessionFile = branched;
|
|
146
|
+
sessionId = branchedId;
|
|
147
|
+
} else {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
// Fallback: no leaf node — create a header-only fork file manually.
|
|
152
|
+
sessionId = crypto.randomUUID();
|
|
153
|
+
const timestamp = new Date().toISOString();
|
|
154
|
+
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
155
|
+
sessionFile = path.join(
|
|
156
|
+
manager.getSessionDir(),
|
|
157
|
+
`${fileTimestamp}_${sessionId}.jsonl`,
|
|
158
|
+
);
|
|
159
|
+
const header = {
|
|
160
|
+
type: "session",
|
|
161
|
+
version: CURRENT_SESSION_VERSION,
|
|
162
|
+
id: sessionId,
|
|
163
|
+
timestamp,
|
|
164
|
+
cwd: manager.getCwd(),
|
|
165
|
+
parentSession: transcriptFile,
|
|
166
|
+
};
|
|
167
|
+
fs.writeFileSync(sessionFile, `${JSON.stringify(header)}\n`, {
|
|
168
|
+
encoding: "utf-8",
|
|
169
|
+
mode: 0o600,
|
|
170
|
+
flag: "wx",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const forkSessionKey = `${orchestratorSessionKey}:fork:${sessionId}`;
|
|
175
|
+
|
|
176
|
+
// Register in session store — fail closed if store is unwritable
|
|
177
|
+
const wrote = writeStoreEntry(sessionsDir, forkSessionKey, {
|
|
178
|
+
sessionId,
|
|
179
|
+
sessionFile: path.relative(sessionsDir, sessionFile),
|
|
180
|
+
updatedAt: Date.now(),
|
|
181
|
+
spawnedBy: orchestratorSessionKey,
|
|
182
|
+
parentSessionKey: orchestratorSessionKey,
|
|
183
|
+
forkedFromParent: true,
|
|
184
|
+
});
|
|
185
|
+
if (!wrote) return null;
|
|
186
|
+
|
|
187
|
+
return { sessionKey: forkSessionKey, sessionId, sessionFile };
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Fork cleanup (store-driven)
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Clean up a fork session: delete transcript file AND session store entry.
|
|
199
|
+
*/
|
|
200
|
+
export function cleanupForkSession(opts: {
|
|
201
|
+
sessionFile: string;
|
|
202
|
+
sessionKey: string;
|
|
203
|
+
sessionsDir: string;
|
|
204
|
+
}): void {
|
|
205
|
+
try {
|
|
206
|
+
if (fs.existsSync(opts.sessionFile)) {
|
|
207
|
+
fs.unlinkSync(opts.sessionFile);
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// Best-effort file cleanup
|
|
211
|
+
}
|
|
212
|
+
deleteStoreEntry(opts.sessionsDir, opts.sessionKey);
|
|
213
|
+
}
|