@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.
- package/openclaw.plugin.json +14 -0
- package/package.json +42 -0
- package/skills/parall-platform/SKILL.md +50 -0
- package/skills/parall-tasks/SKILL.md +44 -0
- package/skills/parall-wiki/SKILL.md +97 -0
- package/src/accounts.ts +38 -0
- package/src/channel.ts +40 -0
- package/src/config-manager.ts +154 -0
- package/src/fork.ts +213 -0
- package/src/gateway.ts +1222 -0
- package/src/hooks.ts +162 -0
- package/src/index.ts +19 -0
- package/src/outbound.ts +37 -0
- package/src/routing.ts +48 -0
- package/src/runtime.ts +135 -0
- package/src/session.ts +36 -0
- package/src/types.ts +18 -0
- package/src/wiki-helper.ts +157 -0
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.
|
package/src/accounts.ts
ADDED
|
@@ -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
|
+
}
|