@parall/parall 1.12.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.
@@ -0,0 +1,14 @@
1
+ {
2
+ "id": "parall",
3
+ "channels": ["parall"],
4
+ "skills": [
5
+ "./skills/parall-wiki",
6
+ "./skills/parall-tasks",
7
+ "./skills/parall-platform"
8
+ ],
9
+ "configSchema": {
10
+ "type": "object",
11
+ "additionalProperties": true,
12
+ "properties": {}
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@parall/parall",
3
+ "version": "1.12.0",
4
+ "description": "OpenClaw channel plugin for Parall IM",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/parall-hq/parall-mono",
9
+ "directory": "ts/openclaw-channel"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "src",
14
+ "skills",
15
+ "openclaw.plugin.json"
16
+ ],
17
+ "dependencies": {
18
+ "@parall/sdk": "1.12.0",
19
+ "@parall/cli": "1.12.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.0.0",
23
+ "openclaw": "2026.4.2",
24
+ "typescript": "^5.7.0"
25
+ },
26
+ "peerDependencies": {
27
+ "@mariozechner/pi-coding-agent": "*",
28
+ "openclaw": ">=2026.4.2"
29
+ },
30
+ "openclaw": {
31
+ "extensions": [
32
+ "./src/index.ts"
33
+ ],
34
+ "install": {
35
+ "minHostVersion": ">=2026.4.2"
36
+ }
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -b",
40
+ "test": "node --test test/*.test.mjs"
41
+ }
42
+ }
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: parall-platform
3
+ description: "Parall platform queries: list org members, agents, chats, read message history, check identity. Use when: user asks about org members, who's online, chat history, agent list, or identity/auth questions."
4
+ ---
5
+
6
+ # Parall Platform
7
+
8
+ Query organization data via the Parall CLI. Auth is pre-configured.
9
+
10
+ ## Identity
11
+
12
+ ```bash
13
+ npx @parall/cli@latest whoami
14
+ ```
15
+
16
+ ## Members & Agents
17
+
18
+ ```bash
19
+ npx @parall/cli@latest members list # All org members (humans + agents)
20
+ npx @parall/cli@latest agents list # Agents only
21
+ npx @parall/cli@latest users get usr_xxx # Get user details by ID
22
+ ```
23
+
24
+ ## Chats & Messages
25
+
26
+ ```bash
27
+ npx @parall/cli@latest chats list # List all chats
28
+ npx @parall/cli@latest messages list cht_xxx # Read chat message history
29
+ ```
30
+
31
+ ## Sending Messages
32
+
33
+ Each `[Event: message.new]` includes `[Chat: ... (cht_xxx)]` — always use that chat ID explicitly.
34
+
35
+ ```bash
36
+ # Reply to a chat (use the chat ID from the event)
37
+ npx @parall/cli@latest messages send cht_xxx --text "Your reply"
38
+
39
+ # Direct message by user ID or display name
40
+ npx @parall/cli@latest dm usr_xxx --text "Hello"
41
+ npx @parall/cli@latest dm "Alice" --text "Hello"
42
+
43
+ # Thread reply
44
+ npx @parall/cli@latest messages send cht_xxx --text "Reply" --thread-root-id 01JWC...
45
+
46
+ # FYI message (no response expected)
47
+ npx @parall/cli@latest messages send cht_xxx --text "FYI: done" --no-reply
48
+ ```
49
+
50
+ All CLI output is JSON.
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: parall-tasks
3
+ description: "Parall task operations: create, update, comment on, and query tasks and projects. Use when: user asks to create a task, update task status, add comments, list tasks, or manage projects."
4
+ ---
5
+
6
+ # Parall Tasks
7
+
8
+ Manage tasks and projects via the Parall CLI. Auth and runtime context are pre-configured.
9
+
10
+ ## Task Commands
11
+
12
+ ```bash
13
+ # List tasks (filterable by status)
14
+ npx @parall/cli@latest tasks list
15
+ npx @parall/cli@latest tasks list --status todo
16
+ npx @parall/cli@latest tasks list --status in_progress
17
+
18
+ # Create a task
19
+ npx @parall/cli@latest tasks create --title "Task title" [--assignee-id usr_xxx] [--project-id prj_xxx]
20
+
21
+ # Update task status
22
+ npx @parall/cli@latest tasks update tsk_xxx --status in_progress
23
+ npx @parall/cli@latest tasks update tsk_xxx --status done
24
+
25
+ # Add a comment
26
+ npx @parall/cli@latest tasks comments add tsk_xxx --body "Progress update..."
27
+ ```
28
+
29
+ ## Project Commands
30
+
31
+ ```bash
32
+ npx @parall/cli@latest projects list
33
+ ```
34
+
35
+ ## Responding to Task Assignments
36
+
37
+ When you receive `[Event: task.assigned]`:
38
+
39
+ 1. Acknowledge with a comment: `tasks comments add tsk_xxx --body "On it"`
40
+ 2. Update status: `tasks update tsk_xxx --status in_progress`
41
+ 3. Do the work
42
+ 4. Report results via comment and update status to `done`
43
+
44
+ All CLI output is JSON.
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: parall-wiki
3
+ description: "Parall wiki operations: read, search, edit, and propose changes to organization knowledge bases. Use when: user asks to read/write docs, edit wiki pages, search knowledge base, propose changes, or review changesets."
4
+ ---
5
+
6
+ # Parall Wiki
7
+
8
+ Manage organization wikis via the Parall CLI. Auth is pre-configured.
9
+
10
+ ## Browsing (no local state needed)
11
+
12
+ ```bash
13
+ npx @parall/cli@latest wiki list # List all wikis
14
+ npx @parall/cli@latest wiki tree <slug> # List files and directories
15
+ npx @parall/cli@latest wiki tree <slug> --path docs/ # List a subdirectory
16
+ ```
17
+
18
+ ## Editing
19
+
20
+ Wiki editing works on a **local workspace** — a directory on disk where wiki
21
+ files are synced. You sync from the server, edit files locally, then propose
22
+ a changeset.
23
+
24
+ ### Step 1: Sync
25
+
26
+ ```bash
27
+ npx @parall/cli@latest wiki sync <slug>
28
+ ```
29
+
30
+ This downloads wiki files to a local directory. The output includes the
31
+ **absolute mount path** for each wiki (e.g. `synced → /data/.openclaw/workspace/kb`).
32
+
33
+ ### Step 2: Find the mount path
34
+
35
+ The sync output JSON contains `synced[].path` — the absolute path where files
36
+ live. You can also check it anytime with:
37
+
38
+ ```bash
39
+ npx @parall/cli@latest wiki status <slug>
40
+ ```
41
+
42
+ The output includes `Mount: /absolute/path/to/<slug>`.
43
+
44
+ Use this path with `read`, `write`, and `edit` tools. For example, if the mount
45
+ is `/data/.openclaw/workspace/kb`, then `README.md` is at
46
+ `/data/.openclaw/workspace/kb/README.md`.
47
+
48
+ ### Step 3: Edit files
49
+
50
+ Use standard file tools (`read`, `write`, `edit`) on files under the mount path.
51
+
52
+ ### Step 4: Review changes
53
+
54
+ ```bash
55
+ npx @parall/cli@latest wiki diff <slug> # Shows unified diff of all local changes
56
+ npx @parall/cli@latest wiki status <slug> # Shows which files changed with +/- line counts
57
+ ```
58
+
59
+ Always review before proposing.
60
+
61
+ ### Step 5: Propose changeset
62
+
63
+ ```bash
64
+ npx @parall/cli@latest wiki changeset create <slug> --title "Description of changes"
65
+ ```
66
+
67
+ This uploads your local changes as a changeset for review.
68
+
69
+ ## Changeset Management
70
+
71
+ ```bash
72
+ npx @parall/cli@latest wiki changeset list <slug> # List all changesets
73
+ npx @parall/cli@latest wiki changeset show <changesetId> <slug> # Show detail + feedback
74
+ npx @parall/cli@latest wiki changeset diff <changesetId> <slug> # Show changeset diff
75
+ ```
76
+
77
+ If a changeset is rejected, fix the files locally and re-propose:
78
+
79
+ ```bash
80
+ npx @parall/cli@latest wiki changeset create <slug> --update <changesetId>
81
+ ```
82
+
83
+ The title is inherited from the original changeset — no need to repeat it.
84
+
85
+ ## Discarding local changes
86
+
87
+ ```bash
88
+ npx @parall/cli@latest wiki reset <slug> # Restore all files to last synced state
89
+ ```
90
+
91
+ ## History
92
+
93
+ ```bash
94
+ npx @parall/cli@latest wiki log <slug> # Recent wiki operations
95
+ ```
96
+
97
+ All CLI output is JSON. Run `npx @parall/cli@latest wiki --help` for full options.
@@ -0,0 +1,38 @@
1
+ import type { ResolvedParallAccount, ParallChannelConfig } from "./types.js";
2
+
3
+ const DEFAULT_ACCOUNT_ID = "default";
4
+
5
+ type OpenClawConfig = Record<string, unknown>;
6
+
7
+ function readParallConfig(cfg: OpenClawConfig): ParallChannelConfig | undefined {
8
+ const channels = cfg.channels as Record<string, unknown> | undefined;
9
+ return channels?.parall as ParallChannelConfig | undefined;
10
+ }
11
+
12
+ export function listParallAccountIds(cfg: OpenClawConfig): string[] {
13
+ const parallCfg = readParallConfig(cfg);
14
+ if (!parallCfg) return [];
15
+ return [DEFAULT_ACCOUNT_ID];
16
+ }
17
+
18
+ export function resolveParallAccount(params: {
19
+ cfg: OpenClawConfig;
20
+ accountId?: string;
21
+ }): ResolvedParallAccount {
22
+ const { cfg, accountId = DEFAULT_ACCOUNT_ID } = params;
23
+ const parallCfg = readParallConfig(cfg);
24
+ const config: ParallChannelConfig = {
25
+ parall_url: parallCfg?.parall_url ?? "",
26
+ api_key: parallCfg?.api_key ?? "",
27
+ org_id: parallCfg?.org_id ?? "",
28
+ ws_url: parallCfg?.ws_url,
29
+ enabled: parallCfg?.enabled,
30
+ };
31
+ const configured = Boolean(config.api_key && config.parall_url && config.org_id);
32
+ return {
33
+ accountId,
34
+ enabled: config.enabled !== false,
35
+ configured,
36
+ config,
37
+ };
38
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
2
+ import { listParallAccountIds, resolveParallAccount } from "./accounts.js";
3
+ import { parallGateway } from "./gateway.js";
4
+ import { parallOutbound } from "./outbound.js";
5
+ import type { ResolvedParallAccount } from "./types.js";
6
+
7
+ const meta: ChannelPlugin["meta"] = {
8
+ id: "parall",
9
+ label: "Parall",
10
+ selectionLabel: "Parall IM",
11
+ docsPath: "/channels/parall",
12
+ blurb: "Agent-Native IM platform.",
13
+ order: 80,
14
+ };
15
+
16
+ export const parallPlugin: ChannelPlugin<ResolvedParallAccount> = {
17
+ id: "parall",
18
+ meta,
19
+ capabilities: {
20
+ chatTypes: ["direct", "group"],
21
+ polls: false,
22
+ threads: false,
23
+ media: true,
24
+ reactions: false,
25
+ edit: false,
26
+ reply: false,
27
+ },
28
+ config: {
29
+ listAccountIds: (cfg) => listParallAccountIds(cfg),
30
+ resolveAccount: (cfg, accountId) => resolveParallAccount({ cfg, accountId: accountId ?? undefined }),
31
+ isConfigured: (account) => account.configured,
32
+ describeAccount: (account) => ({
33
+ accountId: account.accountId,
34
+ enabled: account.enabled,
35
+ configured: account.configured,
36
+ }),
37
+ },
38
+ outbound: parallOutbound,
39
+ gateway: parallGateway,
40
+ };
@@ -0,0 +1,154 @@
1
+ import type { ParallClient } from "@parall/sdk";
2
+ import type { PlatformConfigResponse } from "@parall/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: ParallClient;
14
+ stateDir: string;
15
+ configPath: string;
16
+ credentials: { api_key: string; parall_url: string };
17
+ log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
18
+ }
19
+
20
+ const CACHE_FILENAME = "parall-platform-config.json";
21
+
22
+ /** Tool names are no longer registered — all operations go through CLI. */
23
+
24
+ function cachePath(stateDir: string): string {
25
+ return path.join(stateDir, CACHE_FILENAME);
26
+ }
27
+
28
+ function loadCachedConfig(stateDir: string): CachedPlatformConfig | null {
29
+ try {
30
+ const raw = fs.readFileSync(cachePath(stateDir), "utf-8");
31
+ return JSON.parse(raw) as CachedPlatformConfig;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function saveCachedConfig(stateDir: string, config: PlatformConfigResponse): void {
38
+ const cached: CachedPlatformConfig = {
39
+ version: config.version,
40
+ config: config.config,
41
+ fetchedAt: new Date().toISOString(),
42
+ };
43
+ const filePath = cachePath(stateDir);
44
+ const tmpPath = `${filePath}.tmp`;
45
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
46
+ fs.writeFileSync(tmpPath, JSON.stringify(cached, null, 2), "utf-8");
47
+ fs.renameSync(tmpPath, filePath);
48
+ }
49
+
50
+ /**
51
+ * Deep-merge platform config into an existing openclaw.json file.
52
+ * Only overwrites `models.providers.parall` and `agents.defaults` sections;
53
+ * all other keys (user customizations, other providers) are preserved.
54
+ */
55
+ function applyToOpenClawConfig(
56
+ configPath: string,
57
+ platformConfig: Record<string, unknown>,
58
+ credentials: { api_key: string; parall_url: string },
59
+ ): void {
60
+ let existing: Record<string, unknown> = {};
61
+ try {
62
+ const raw = fs.readFileSync(configPath, "utf-8");
63
+ existing = JSON.parse(raw) as Record<string, unknown>;
64
+ } catch {
65
+ // File doesn't exist or is invalid — start fresh
66
+ }
67
+
68
+ // Deep-merge models.providers.parall (preserve existing fields, overlay platform, inject credentials)
69
+ const models = (existing.models ?? {}) as Record<string, unknown>;
70
+ const providers = (models.providers ?? {}) as Record<string, unknown>;
71
+ const existingParall = (providers.parall ?? {}) as Record<string, unknown>;
72
+ const platformModels = (platformConfig.models ?? {}) as Record<string, unknown>;
73
+ const platformProviders = (platformModels.providers ?? {}) as Record<string, unknown>;
74
+ const platformParall = (platformProviders.parall ?? {}) as Record<string, unknown>;
75
+
76
+ providers.parall = {
77
+ ...existingParall,
78
+ ...platformParall,
79
+ apiKey: credentials.api_key,
80
+ };
81
+ models.providers = providers;
82
+ existing.models = models;
83
+
84
+ // Deep-merge agents.defaults (preserve existing fields, overlay platform)
85
+ const platformAgents = (platformConfig.agents ?? {}) as Record<string, unknown>;
86
+ if (platformAgents.defaults !== undefined) {
87
+ const agents = (existing.agents ?? {}) as Record<string, unknown>;
88
+ const existingDefaults = (agents.defaults ?? {}) as Record<string, unknown>;
89
+ agents.defaults = {
90
+ ...existingDefaults,
91
+ ...(platformAgents.defaults as Record<string, unknown>),
92
+ };
93
+ existing.agents = agents;
94
+ }
95
+
96
+ // No plugin tools to allow — all operations go through CLI.
97
+
98
+ // Atomic write
99
+ const tmpPath = `${configPath}.tmp`;
100
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
101
+ fs.writeFileSync(tmpPath, JSON.stringify(existing, null, 2), "utf-8");
102
+ fs.renameSync(tmpPath, configPath);
103
+ }
104
+
105
+ /**
106
+ * Fetch platform config from the server, cache it (LKG), and apply to openclaw.json.
107
+ * Degrades gracefully: uses cached config on fetch failure, continues if neither is available.
108
+ */
109
+ export async function fetchAndApplyPlatformConfig(opts: ConfigManagerOpts): Promise<void> {
110
+ const { client, stateDir, configPath, credentials, log } = opts;
111
+
112
+ // 1. Load cached config (LKG)
113
+ const cached = loadCachedConfig(stateDir);
114
+
115
+ // 2. Fetch from server (with version for 304)
116
+ let fresh: PlatformConfigResponse | null = null;
117
+ try {
118
+ fresh = await client.getPlatformConfig(cached?.version);
119
+ } catch (err) {
120
+ // Fetch failed — fall through to use cache
121
+ if (cached) {
122
+ log?.warn(`platform config fetch failed, using cached version ${cached.version}: ${String(err)}`);
123
+ applyToOpenClawConfig(configPath, cached.config, credentials);
124
+ return;
125
+ }
126
+ // No cache and fetch fails — degrade gracefully
127
+ log?.error(`platform config fetch failed and no cache available: ${String(err)}`);
128
+ return;
129
+ }
130
+
131
+ // 3. 304 — config unchanged
132
+ if (fresh === null) {
133
+ log?.info("platform config unchanged (304)");
134
+ if (cached) {
135
+ applyToOpenClawConfig(configPath, cached.config, credentials);
136
+ }
137
+ return;
138
+ }
139
+
140
+ // 4. Validate schema compatibility before accepting
141
+ const SUPPORTED_SCHEMA_VERSION = 1;
142
+ if (fresh.schema_version !== undefined && fresh.schema_version > SUPPORTED_SCHEMA_VERSION) {
143
+ log?.error(`platform config schema_version ${fresh.schema_version} is newer than supported (${SUPPORTED_SCHEMA_VERSION}), keeping current config`);
144
+ if (cached) {
145
+ applyToOpenClawConfig(configPath, cached.config, credentials);
146
+ }
147
+ return;
148
+ }
149
+
150
+ // 5. Newer config received — save cache and apply
151
+ log?.info(`platform config updated to version ${fresh.version}`);
152
+ saveCachedConfig(stateDir, fresh);
153
+ applyToOpenClawConfig(configPath, fresh.config, credentials);
154
+ }
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
+ }