@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 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.0",
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
- "@pnds/sdk": "1.1.0"
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
- "openclaw": ">=2025"
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, ChannelMeta } from "openclaw/plugin-sdk";
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: ChannelMeta = {
7
+ const meta: ChannelPlugin["meta"] = {
8
8
  id: "pond",
9
9
  label: "Pond",
10
10
  selectionLabel: "Pond IM",
@@ -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
+ }