@pnds/pond 1.0.1 → 1.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.
- package/TOOLS.md +54 -0
- package/package.json +17 -4
- package/src/action-tools.ts +223 -0
- package/src/channel.ts +2 -2
- package/src/fork.ts +213 -0
- package/src/gateway.ts +721 -143
- package/src/hooks.ts +33 -17
- 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,54 @@
|
|
|
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
|
|
27
|
+
|
|
28
|
+
You also have access to the Pond platform via the `@pnds/cli` CLI. Auth is pre-configured.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx @pnds/cli@latest whoami # Check your identity
|
|
32
|
+
npx @pnds/cli@latest agents list # List all agents (name, model, status)
|
|
33
|
+
npx @pnds/cli@latest dm <name-or-id> --text "..." # DM a user by name or ID (auto-creates chat)
|
|
34
|
+
npx @pnds/cli@latest dm <name-or-id> --text "..." --no-reply # DM, signal no reply expected
|
|
35
|
+
npx @pnds/cli@latest chats list # List chats
|
|
36
|
+
npx @pnds/cli@latest messages list <chatId> # Read chat history
|
|
37
|
+
npx @pnds/cli@latest messages send <chatId> --text "..." # Send a message
|
|
38
|
+
npx @pnds/cli@latest messages send <chatId> --text "..." --no-reply # Send, no reply expected
|
|
39
|
+
npx @pnds/cli@latest tasks list [--status ...] # List tasks
|
|
40
|
+
npx @pnds/cli@latest tasks create --title "..." # Create a task
|
|
41
|
+
npx @pnds/cli@latest tasks update <taskId> --status in_progress
|
|
42
|
+
npx @pnds/cli@latest projects list # List projects
|
|
43
|
+
npx @pnds/cli@latest users search <query> # Find users
|
|
44
|
+
npx @pnds/cli@latest members list # List org members
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Run `npx @pnds/cli@latest --help` or `npx @pnds/cli@latest <command> --help` for full options. Output is JSON.
|
|
48
|
+
|
|
49
|
+
## Agent-to-Agent Interaction
|
|
50
|
+
|
|
51
|
+
When you receive a message from another agent (not a human):
|
|
52
|
+
- If the message has a no_reply hint, you may still reason and use tools, but your response will not be sent to chat.
|
|
53
|
+
- If you have nothing meaningful to add to the conversation, produce an empty response to avoid unnecessary back-and-forth.
|
|
54
|
+
- Use --no-reply when sending messages that don't require a response (e.g., delivering results, FYI notifications).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pnds/pond",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
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/cli": "1.2.0",
|
|
20
|
+
"@pnds/sdk": "1.2.0"
|
|
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,223 @@
|
|
|
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
|
+
noReply: Type.Optional(Type.Boolean({ description: "If true, hints that no reply is expected." })),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const TaskCommentSchema = Type.Object({
|
|
57
|
+
taskId: Type.String({ minLength: 1, description: "Task ID to comment on." }),
|
|
58
|
+
body: Type.String({ minLength: 1, description: "Comment body text." }),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const TaskUpdateSchema = Type.Object({
|
|
62
|
+
taskId: Type.String({ minLength: 1, description: "Task ID to update." }),
|
|
63
|
+
status: Type.Optional(Type.String({ description: "New task status." })),
|
|
64
|
+
title: Type.Optional(Type.String({ description: "New task title." })),
|
|
65
|
+
description: Type.Optional(Type.String({ description: "New task description." })),
|
|
66
|
+
priority: Type.Optional(Type.String({ description: "New task priority." })),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const ChatHistorySchema = Type.Object({
|
|
70
|
+
chatId: Type.String({ minLength: 1, description: "Chat ID to read messages from." }),
|
|
71
|
+
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50, description: "Number of messages to fetch (default 20, max 50)." })),
|
|
72
|
+
before: Type.Optional(Type.String({ description: "Message ID cursor — fetch messages before this ID." })),
|
|
73
|
+
threadRootId: Type.Optional(Type.String({ description: "If provided, fetch messages within this thread instead of top-level messages." })),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const TypingSchema = Type.Object({
|
|
77
|
+
chatId: Type.String({ minLength: 1, description: "Chat ID for the typing indicator." }),
|
|
78
|
+
action: Type.Union([Type.Literal("start"), Type.Literal("stop")], { description: "Start or stop the typing indicator." }),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---- Helpers ----
|
|
82
|
+
|
|
83
|
+
function formatMessage(msg: Message): string {
|
|
84
|
+
const senderName = msg.sender?.display_name ?? msg.sender_id;
|
|
85
|
+
const content = msg.content as Record<string, unknown>;
|
|
86
|
+
if (msg.message_type === "text") {
|
|
87
|
+
return `${senderName} (${msg.sender_id}): ${(content as { text?: string }).text ?? ""}`;
|
|
88
|
+
}
|
|
89
|
+
if (msg.message_type === "file") {
|
|
90
|
+
return `${senderName} (${msg.sender_id}): [file: ${(content as { file_name?: string }).file_name ?? "unknown"}]`;
|
|
91
|
+
}
|
|
92
|
+
if (msg.message_type === "system") {
|
|
93
|
+
return `[system: ${(content as { text?: string }).text ?? JSON.stringify(content)}]`;
|
|
94
|
+
}
|
|
95
|
+
return `${senderName} (${msg.sender_id}): [${msg.message_type}]`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- Tool factory ----
|
|
99
|
+
|
|
100
|
+
function createActionTools(toolCtx: ToolSessionContext): AnyAgentTool[] {
|
|
101
|
+
const state = () => resolvePondState(toolCtx);
|
|
102
|
+
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
label: "Pond Reply",
|
|
106
|
+
name: "pond_reply",
|
|
107
|
+
description: "Send a text message to a Pond chat.",
|
|
108
|
+
parameters: ReplySchema,
|
|
109
|
+
execute: async (_toolCallId, params) => {
|
|
110
|
+
const s = state();
|
|
111
|
+
const chatId = readStringParam(params, "chatId", { required: true });
|
|
112
|
+
const text = readStringParam(params, "text", { required: true });
|
|
113
|
+
const noReply = params.noReply === true;
|
|
114
|
+
const runId = s.activeRuns && toolCtx.sessionKey
|
|
115
|
+
? await getActiveRunId(s, toolCtx.sessionKey)
|
|
116
|
+
: undefined;
|
|
117
|
+
|
|
118
|
+
// Deterministic no_reply suppression: if the inbound event had no_reply hint,
|
|
119
|
+
// hard-block the send and record a suppressed step instead of posting to chat.
|
|
120
|
+
// This prevents agent-to-agent reply loops at the plugin layer (not just prompt).
|
|
121
|
+
if (toolCtx.sessionKey && getDispatchNoReply(toolCtx.sessionKey)) {
|
|
122
|
+
if (runId) {
|
|
123
|
+
try {
|
|
124
|
+
await s.client.createAgentStep(s.orgId, s.agentUserId, runId, {
|
|
125
|
+
step_type: "text",
|
|
126
|
+
content: { text, suppressed: true, reason: "no_reply" },
|
|
127
|
+
});
|
|
128
|
+
} catch { /* best-effort step recording */ }
|
|
129
|
+
}
|
|
130
|
+
return jsonResult({ suppressed: true, reason: "no_reply", chat_id: chatId });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const result = await s.client.sendMessage(s.orgId, chatId, {
|
|
134
|
+
message_type: "text",
|
|
135
|
+
content: { text },
|
|
136
|
+
agent_run_id: runId,
|
|
137
|
+
...(noReply ? { hints: { no_reply: true } } : {}),
|
|
138
|
+
});
|
|
139
|
+
return jsonResult({ message_id: result.id, chat_id: chatId });
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
label: "Pond Task Comment",
|
|
144
|
+
name: "pond_task_comment",
|
|
145
|
+
description: "Post a comment on a Pond task.",
|
|
146
|
+
parameters: TaskCommentSchema,
|
|
147
|
+
execute: async (_toolCallId, params) => {
|
|
148
|
+
const s = state();
|
|
149
|
+
const taskId = readStringParam(params, "taskId", { required: true });
|
|
150
|
+
const body = readStringParam(params, "body", { required: true });
|
|
151
|
+
const result = await s.client.createTaskComment(s.orgId, taskId, { body });
|
|
152
|
+
return jsonResult(result);
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
label: "Pond Task Update",
|
|
157
|
+
name: "pond_task_update",
|
|
158
|
+
description: "Update fields on a Pond task (status, title, description, priority).",
|
|
159
|
+
parameters: TaskUpdateSchema,
|
|
160
|
+
execute: async (_toolCallId, params) => {
|
|
161
|
+
const s = state();
|
|
162
|
+
const taskId = readStringParam(params, "taskId", { required: true });
|
|
163
|
+
const update: Record<string, string> = {};
|
|
164
|
+
for (const field of ["status", "title", "description", "priority"] as const) {
|
|
165
|
+
const val = readStringParam(params, field);
|
|
166
|
+
if (val !== undefined) update[field] = val;
|
|
167
|
+
}
|
|
168
|
+
const result = await s.client.updateTask(s.orgId, taskId, update);
|
|
169
|
+
return jsonResult(result);
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
label: "Pond Chat History",
|
|
174
|
+
name: "pond_chat_history",
|
|
175
|
+
description: "Read recent messages from a Pond chat. Use this to get context on unfamiliar conversations.",
|
|
176
|
+
parameters: ChatHistorySchema,
|
|
177
|
+
execute: async (_toolCallId, params) => {
|
|
178
|
+
const s = state();
|
|
179
|
+
const chatId = readStringParam(params, "chatId", { required: true });
|
|
180
|
+
const limit = Math.min(readNumberParam(params, "limit", { integer: true }) ?? 20, 50);
|
|
181
|
+
const before = readStringParam(params, "before");
|
|
182
|
+
const threadRootId = readStringParam(params, "threadRootId");
|
|
183
|
+
const threadParams = threadRootId
|
|
184
|
+
? { thread_root_id: threadRootId }
|
|
185
|
+
: { top_level: true as const };
|
|
186
|
+
const result = await s.client.getMessages(s.orgId, chatId, {
|
|
187
|
+
limit,
|
|
188
|
+
before,
|
|
189
|
+
...threadParams,
|
|
190
|
+
});
|
|
191
|
+
const messages = result.data.map(formatMessage);
|
|
192
|
+
return jsonResult({
|
|
193
|
+
messages,
|
|
194
|
+
has_more: result.has_more,
|
|
195
|
+
...(result.next_cursor ? { next_cursor: result.next_cursor } : {}),
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
label: "Pond Typing",
|
|
201
|
+
name: "pond_typing",
|
|
202
|
+
description: "Start or stop the typing indicator in a Pond chat.",
|
|
203
|
+
parameters: TypingSchema,
|
|
204
|
+
execute: async (_toolCallId, params) => {
|
|
205
|
+
const s = state();
|
|
206
|
+
if (!s.ws) {
|
|
207
|
+
return jsonResult({ error: "WebSocket not available — typing indicator requires a live connection." });
|
|
208
|
+
}
|
|
209
|
+
const chatId = readStringParam(params, "chatId", { required: true });
|
|
210
|
+
const action = readStringParam(params, "action", { required: true }) as "start" | "stop";
|
|
211
|
+
s.ws.sendTyping(chatId, action);
|
|
212
|
+
return jsonResult({ ok: true });
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function registerPondActionTools(api: OpenClawPluginApi) {
|
|
219
|
+
api.registerTool(
|
|
220
|
+
(ctx) => createActionTools(ctx),
|
|
221
|
+
{ names: ["pond_reply", "pond_task_comment", "pond_task_update", "pond_chat_history", "pond_typing"] },
|
|
222
|
+
);
|
|
223
|
+
}
|
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/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
|
+
}
|