@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/src/hooks.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { extractAccountIdFromSessionKey } from "./session.js";
|
|
3
|
+
import { clearDispatchGroupKey, clearDispatchMessageId, clearSessionMessageId, getDispatchGroupKey, getDispatchMessageId, getParallAccountState, getSessionChatId } from "./runtime.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the ParallClient + orgId + sessionId for a hook context.
|
|
7
|
+
* Returns undefined if the account/session is not active.
|
|
8
|
+
*/
|
|
9
|
+
function resolveClientForHook(sessionKey: string | undefined) {
|
|
10
|
+
if (!sessionKey) return undefined;
|
|
11
|
+
const chatId = getSessionChatId(sessionKey);
|
|
12
|
+
if (!chatId) return undefined;
|
|
13
|
+
const accountId = extractAccountIdFromSessionKey(sessionKey);
|
|
14
|
+
if (!accountId) return undefined;
|
|
15
|
+
|
|
16
|
+
const state = getParallAccountState(accountId);
|
|
17
|
+
if (!state) return undefined;
|
|
18
|
+
|
|
19
|
+
return { ...state, chatId, sessionId: state.activeSessionId };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PRLL_CHANNEL_CONTEXT = `## Parall Channel — Message Delivery
|
|
23
|
+
|
|
24
|
+
Messages from the Parall channel arrive as \`[Event: ...]\` blocks.
|
|
25
|
+
Your text output is **not delivered to the user** — it is discarded silently.
|
|
26
|
+
To reply, you **must** use the Parall CLI via the exec (Bash) tool.
|
|
27
|
+
|
|
28
|
+
Each event includes \`[Chat: ... (cht_xxx)]\` — use that chat ID to reply:
|
|
29
|
+
|
|
30
|
+
npx @parall/cli@latest messages send cht_xxx --text "Your reply here"
|
|
31
|
+
|
|
32
|
+
Credentials are pre-configured in every Bash command — no setup needed.
|
|
33
|
+
Load the \`parall-platform\` skill for full CLI reference.
|
|
34
|
+
When an event has \`[Hint: no_reply]\`, do not reply to that specific event.
|
|
35
|
+
A dispatch may contain multiple events — only skip replies to the hinted ones.`;
|
|
36
|
+
|
|
37
|
+
export function registerParallHooks(api: OpenClawPluginApi) {
|
|
38
|
+
const log = api.logger;
|
|
39
|
+
|
|
40
|
+
// Append Parall channel context AFTER workspace files so it takes precedence
|
|
41
|
+
api.on("before_prompt_build", () => {
|
|
42
|
+
return { appendSystemContext: PRLL_CHANNEL_CONTEXT };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// before_tool_call -> (1) await step creation to get step ID, (2) ENV injection for exec tool
|
|
46
|
+
api.on("before_tool_call", async (event, ctx) => {
|
|
47
|
+
const sessionKey = ctx.sessionKey;
|
|
48
|
+
|
|
49
|
+
// (1) Create tool_call step and await to get step ID
|
|
50
|
+
let stepId: string | undefined;
|
|
51
|
+
const resolved = resolveClientForHook(sessionKey);
|
|
52
|
+
if (resolved?.sessionId && event.toolCallId) {
|
|
53
|
+
const groupKey = sessionKey ? getDispatchGroupKey(sessionKey) : undefined;
|
|
54
|
+
try {
|
|
55
|
+
const step = await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.sessionId, {
|
|
56
|
+
step_type: "tool_call",
|
|
57
|
+
target_type: resolved.chatId.startsWith("cht_") ? "chat" : resolved.chatId.startsWith("tsk_") ? "task" : "",
|
|
58
|
+
target_id: resolved.chatId || undefined,
|
|
59
|
+
content: {
|
|
60
|
+
call_id: event.toolCallId,
|
|
61
|
+
tool_name: event.toolName,
|
|
62
|
+
tool_input: event.params ?? {},
|
|
63
|
+
status: "running",
|
|
64
|
+
started_at: new Date().toISOString(),
|
|
65
|
+
},
|
|
66
|
+
group_key: groupKey,
|
|
67
|
+
runtime_key: event.toolCallId,
|
|
68
|
+
});
|
|
69
|
+
stepId = step.id;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
log?.warn(`parall hook before_tool_call failed: ${String(err)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// (2) ENV injection — only for the exec (Bash) tool
|
|
76
|
+
if (event.toolName !== "exec") return;
|
|
77
|
+
if (!sessionKey) return;
|
|
78
|
+
const accountId = extractAccountIdFromSessionKey(sessionKey);
|
|
79
|
+
if (!accountId) return;
|
|
80
|
+
const state = getParallAccountState(accountId);
|
|
81
|
+
if (!state) return;
|
|
82
|
+
|
|
83
|
+
// Dynamic per-dispatch context (changes each dispatch)
|
|
84
|
+
const chatId = getSessionChatId(sessionKey);
|
|
85
|
+
const triggerMsgId = getDispatchMessageId(sessionKey);
|
|
86
|
+
|
|
87
|
+
const injectedEnv: Record<string, string> = {};
|
|
88
|
+
if (state.activeSessionId) injectedEnv.PRLL_SESSION_ID = state.activeSessionId;
|
|
89
|
+
if (stepId) injectedEnv.PRLL_STEP_ID = stepId;
|
|
90
|
+
if (chatId) injectedEnv.PRLL_CHAT_ID = chatId;
|
|
91
|
+
if (triggerMsgId) injectedEnv.PRLL_TRIGGER_MESSAGE_ID = triggerMsgId;
|
|
92
|
+
if (state.wikiMountRoot) injectedEnv.PRLL_WIKI_MOUNT_ROOT = state.wikiMountRoot;
|
|
93
|
+
|
|
94
|
+
// OpenClaw context (upstream doesn't inject these into exec env yet)
|
|
95
|
+
injectedEnv.OPENCLAW_SESSION_KEY = sessionKey;
|
|
96
|
+
if (event.toolCallId) injectedEnv.OPENCLAW_TOOL_CALL_ID = event.toolCallId;
|
|
97
|
+
|
|
98
|
+
// Shallow merge — preserve agent's original env params
|
|
99
|
+
const existingEnv = (event.params as Record<string, unknown>)?.env as Record<string, string> | undefined;
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
params: {
|
|
103
|
+
env: { ...existingEnv, ...injectedEnv },
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// after_tool_call -> send tool_result step to AgentSession
|
|
109
|
+
api.on("after_tool_call", async (event, ctx) => {
|
|
110
|
+
const resolved = resolveClientForHook(ctx.sessionKey);
|
|
111
|
+
if (!resolved?.sessionId || !event.toolCallId) return;
|
|
112
|
+
try {
|
|
113
|
+
await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.sessionId, {
|
|
114
|
+
step_type: "tool_result",
|
|
115
|
+
target_type: resolved.chatId.startsWith("cht_") ? "chat" : resolved.chatId.startsWith("tsk_") ? "task" : "",
|
|
116
|
+
target_id: resolved.chatId || undefined,
|
|
117
|
+
content: {
|
|
118
|
+
call_id: event.toolCallId,
|
|
119
|
+
tool_name: event.toolName,
|
|
120
|
+
status: event.error ? "error" : "success",
|
|
121
|
+
output: event.error ?? (typeof event.result === "string" ? event.result : JSON.stringify(event.result ?? "")),
|
|
122
|
+
duration_ms: event.durationMs ?? 0,
|
|
123
|
+
collapsible: true,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
log?.warn(`parall hook after_tool_call failed: ${String(err)}`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// agent_end -> end of a dispatch cycle, NOT end of session.
|
|
132
|
+
// Session lives across dispatches — don't update session status here.
|
|
133
|
+
// On error: emit a visible error step so the chat user sees the failure.
|
|
134
|
+
api.on("agent_end", async (event, ctx) => {
|
|
135
|
+
// Emit error step if the dispatch failed
|
|
136
|
+
if (event.error && ctx.sessionKey) {
|
|
137
|
+
const accountId = extractAccountIdFromSessionKey(ctx.sessionKey);
|
|
138
|
+
const state = accountId ? getParallAccountState(accountId) : undefined;
|
|
139
|
+
const chatId = getSessionChatId(ctx.sessionKey);
|
|
140
|
+
if (state?.activeSessionId && chatId) {
|
|
141
|
+
try {
|
|
142
|
+
await state.client.createAgentStep(state.orgId, state.agentUserId, state.activeSessionId, {
|
|
143
|
+
step_type: "text",
|
|
144
|
+
target_type: "chat",
|
|
145
|
+
target_id: chatId,
|
|
146
|
+
content: { text: `Dispatch failed: ${String(event.error)}`, suppressed: false },
|
|
147
|
+
projection: true,
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
log?.warn(`parall hook agent_end: failed to emit error step: ${String(err)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Clean up dispatch-specific state
|
|
156
|
+
if (ctx.sessionKey) {
|
|
157
|
+
clearSessionMessageId(ctx.sessionKey);
|
|
158
|
+
clearDispatchMessageId(ctx.sessionKey);
|
|
159
|
+
clearDispatchGroupKey(ctx.sessionKey);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import { parallPlugin } from "./channel.js";
|
|
4
|
+
import { setParallRuntime } from "./runtime.js";
|
|
5
|
+
import { registerParallHooks } from "./hooks.js";
|
|
6
|
+
|
|
7
|
+
const plugin = {
|
|
8
|
+
id: "parall",
|
|
9
|
+
name: "Parall",
|
|
10
|
+
description: "Parall IM channel plugin — Agent-Native messaging with tool call visualization.",
|
|
11
|
+
configSchema: emptyPluginConfigSchema(),
|
|
12
|
+
register(api: OpenClawPluginApi) {
|
|
13
|
+
setParallRuntime(api.runtime);
|
|
14
|
+
api.registerChannel({ plugin: parallPlugin });
|
|
15
|
+
registerParallHooks(api);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default plugin;
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
2
|
+
|
|
3
|
+
/** Extract ChannelOutboundAdapter from ChannelPlugin (removed from public SDK exports in 2026.3.24). */
|
|
4
|
+
type ChannelOutboundAdapter = NonNullable<ChannelPlugin["outbound"]>;
|
|
5
|
+
import { resolveParallAccount } from "./accounts.js";
|
|
6
|
+
import { getParallAccountState } from "./runtime.js";
|
|
7
|
+
import { ParallClient } from "@parall/sdk";
|
|
8
|
+
import type { SendMessageRequest } from "@parall/sdk";
|
|
9
|
+
|
|
10
|
+
export const parallOutbound: ChannelOutboundAdapter = {
|
|
11
|
+
deliveryMode: "direct",
|
|
12
|
+
textChunkLimit: 10000,
|
|
13
|
+
chunker: null,
|
|
14
|
+
|
|
15
|
+
sendText: async ({ cfg, to, text, accountId }) => {
|
|
16
|
+
const account = resolveParallAccount({ cfg, accountId: accountId ?? undefined });
|
|
17
|
+
if (!account.enabled) throw new Error("Parall account is disabled");
|
|
18
|
+
if (!account.configured) throw new Error("Parall account is not configured: parall_url/api_key/org_id required");
|
|
19
|
+
|
|
20
|
+
const state = getParallAccountState(account.accountId);
|
|
21
|
+
const client = state?.client ?? new ParallClient({
|
|
22
|
+
baseUrl: account.config.parall_url,
|
|
23
|
+
token: account.config.api_key,
|
|
24
|
+
});
|
|
25
|
+
const orgId = state?.orgId ?? account.config.org_id;
|
|
26
|
+
const chatId = to;
|
|
27
|
+
|
|
28
|
+
// Outbound path has no step context — send without agent_step_id.
|
|
29
|
+
// Step linkage happens via the CLI's ENV-injected PRLL_STEP_ID in the normal dispatch path.
|
|
30
|
+
const req: SendMessageRequest = {
|
|
31
|
+
message_type: "text",
|
|
32
|
+
content: { text },
|
|
33
|
+
};
|
|
34
|
+
const msg = await client.sendMessage(orgId, chatId, req);
|
|
35
|
+
return { channel: "parall", messageId: msg.id, channelId: chatId };
|
|
36
|
+
},
|
|
37
|
+
};
|
package/src/routing.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ParallEvent, DispatchState } from "./runtime.js";
|
|
2
|
+
|
|
3
|
+
/** Where an inbound event should be routed. */
|
|
4
|
+
export type TriggerDisposition =
|
|
5
|
+
| { action: "main" }
|
|
6
|
+
| { action: "buffer-main" }
|
|
7
|
+
| { action: "buffer-fork"; forkKey: string }
|
|
8
|
+
| { action: "new-fork" };
|
|
9
|
+
|
|
10
|
+
/** Pluggable strategy for routing triggers when main session is busy. */
|
|
11
|
+
export type RoutingStrategy = (event: ParallEvent, state: DispatchState) => TriggerDisposition;
|
|
12
|
+
|
|
13
|
+
const MAX_CONCURRENT_FORKS = 20;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default routing strategy:
|
|
17
|
+
* - Same target as main → buffer-main (strict per-target serial ordering)
|
|
18
|
+
* - Active fork for same target → buffer into that fork
|
|
19
|
+
* - Too many forks → buffer-main (backpressure)
|
|
20
|
+
* - Otherwise → create a new fork
|
|
21
|
+
*/
|
|
22
|
+
export const defaultRoutingStrategy: RoutingStrategy = (event, state) => {
|
|
23
|
+
// P0: same target as main must stay serial — fork would see stale transcript
|
|
24
|
+
if (state.mainCurrentTargetId === event.targetId) {
|
|
25
|
+
return { action: "buffer-main" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Same target already has a fork → buffer into it
|
|
29
|
+
const existingForkKey = state.activeForks.get(event.targetId);
|
|
30
|
+
if (existingForkKey) return { action: "buffer-fork", forkKey: existingForkKey };
|
|
31
|
+
|
|
32
|
+
// Backpressure: cap concurrent forks to avoid unbounded fan-out
|
|
33
|
+
if (state.activeForks.size >= MAX_CONCURRENT_FORKS) {
|
|
34
|
+
return { action: "buffer-main" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { action: "new-fork" };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Route an inbound event based on current dispatch state. */
|
|
41
|
+
export function routeTrigger(
|
|
42
|
+
event: ParallEvent,
|
|
43
|
+
state: DispatchState,
|
|
44
|
+
strategy: RoutingStrategy = defaultRoutingStrategy,
|
|
45
|
+
): TriggerDisposition {
|
|
46
|
+
if (!state.mainDispatching) return { action: "main" };
|
|
47
|
+
return strategy(event, state);
|
|
48
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { ParallClient, ParallWs } from "@parall/sdk";
|
|
3
|
+
|
|
4
|
+
let runtime: PluginRuntime | null = null;
|
|
5
|
+
|
|
6
|
+
export function setParallRuntime(next: PluginRuntime) {
|
|
7
|
+
runtime = next;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getParallRuntime(): PluginRuntime {
|
|
11
|
+
if (!runtime) {
|
|
12
|
+
throw new Error("Parall runtime not initialized");
|
|
13
|
+
}
|
|
14
|
+
return runtime;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Cached client state per account, populated by gateway.ts startAccount
|
|
18
|
+
export type ParallAccountState = {
|
|
19
|
+
client: ParallClient;
|
|
20
|
+
orgId: string;
|
|
21
|
+
agentUserId: string;
|
|
22
|
+
activeSessionId?: string;
|
|
23
|
+
wikiMountRoot?: string;
|
|
24
|
+
ws?: ParallWs;
|
|
25
|
+
orchestratorSessionKey?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const accountStates = new Map<string, ParallAccountState>();
|
|
29
|
+
|
|
30
|
+
export function setParallAccountState(accountId: string, state: ParallAccountState) {
|
|
31
|
+
accountStates.set(accountId, state);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function removeParallAccountState(accountId: string) {
|
|
35
|
+
accountStates.delete(accountId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getParallAccountState(accountId: string): ParallAccountState | undefined {
|
|
39
|
+
return accountStates.get(accountId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getAllParallAccountStates(): ReadonlyMap<string, ParallAccountState> {
|
|
43
|
+
return new Map(accountStates);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Session key → original chat ID mapping.
|
|
47
|
+
// OpenClaw normalizes session keys to lowercase, but Parall IDs are case-sensitive.
|
|
48
|
+
const sessionChatIdMap = new Map<string, string>();
|
|
49
|
+
const sessionMessageIdMap = new Map<string, string>();
|
|
50
|
+
// Per-dispatch snapshot: message ID captured when a dispatch starts, immune to later overwrites.
|
|
51
|
+
// Keyed by lowercased session key, cleared when the agent run ends.
|
|
52
|
+
const dispatchMessageIdMap = new Map<string, string>();
|
|
53
|
+
|
|
54
|
+
export function setSessionChatId(sessionKey: string, chatId: string) {
|
|
55
|
+
sessionChatIdMap.set(sessionKey.toLowerCase(), chatId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getSessionChatId(sessionKey: string): string | undefined {
|
|
59
|
+
return sessionChatIdMap.get(sessionKey.toLowerCase());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function setSessionMessageId(sessionKey: string, messageId: string) {
|
|
63
|
+
sessionMessageIdMap.set(sessionKey.toLowerCase(), messageId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getSessionMessageId(sessionKey: string): string | undefined {
|
|
67
|
+
return sessionMessageIdMap.get(sessionKey.toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function clearSessionMessageId(sessionKey: string) {
|
|
71
|
+
sessionMessageIdMap.delete(sessionKey.toLowerCase());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Snapshot the message ID at dispatch time — immune to later session-level overwrites. */
|
|
75
|
+
export function setDispatchMessageId(sessionKey: string, messageId: string) {
|
|
76
|
+
dispatchMessageIdMap.set(sessionKey.toLowerCase(), messageId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read the dispatch-time snapshot. Prefer this over getSessionMessageId for provenance. */
|
|
80
|
+
export function getDispatchMessageId(sessionKey: string): string | undefined {
|
|
81
|
+
return dispatchMessageIdMap.get(sessionKey.toLowerCase());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function clearDispatchMessageId(sessionKey: string) {
|
|
85
|
+
dispatchMessageIdMap.delete(sessionKey.toLowerCase());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Per-dispatch group key — shared between thinking + tool_call steps for Session Panel grouping.
|
|
89
|
+
// Set per LLM response turn in gateway.ts, read in hooks.ts.
|
|
90
|
+
const dispatchGroupKeyMap = new Map<string, string>();
|
|
91
|
+
|
|
92
|
+
export function setDispatchGroupKey(sessionKey: string, groupKey: string) {
|
|
93
|
+
dispatchGroupKeyMap.set(sessionKey.toLowerCase(), groupKey);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getDispatchGroupKey(sessionKey: string): string | undefined {
|
|
97
|
+
return dispatchGroupKeyMap.get(sessionKey.toLowerCase());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function clearDispatchGroupKey(sessionKey: string) {
|
|
101
|
+
dispatchGroupKeyMap.delete(sessionKey.toLowerCase());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Result from a completed fork dispatch, to be injected into main session's next turn. */
|
|
105
|
+
export type ForkResult = {
|
|
106
|
+
forkSessionKey: string;
|
|
107
|
+
sourceEvent: { type: string; targetId: string; summary: string };
|
|
108
|
+
actions: string[]; // human-readable list of actions taken (e.g. "replied to cht_xxx")
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Mutable dispatch state for the orchestrator gateway. Per-account, managed by gateway.ts. */
|
|
112
|
+
export type DispatchState = {
|
|
113
|
+
mainDispatching: boolean;
|
|
114
|
+
/** The targetId currently being processed by main. Used to enforce per-target serial ordering. */
|
|
115
|
+
mainCurrentTargetId?: string;
|
|
116
|
+
/** targetId → fork session key. One active fork per target at most. */
|
|
117
|
+
activeForks: Map<string, string>;
|
|
118
|
+
pendingForkResults: ForkResult[];
|
|
119
|
+
mainBuffer: ParallEvent[];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Normalized inbound event from Parall. */
|
|
123
|
+
export type ParallEvent = {
|
|
124
|
+
type: "message" | "task";
|
|
125
|
+
targetId: string; // chatId or taskId
|
|
126
|
+
targetName?: string;
|
|
127
|
+
targetType?: string; // "direct" | "group" | "task"
|
|
128
|
+
senderId: string;
|
|
129
|
+
senderName: string;
|
|
130
|
+
messageId: string;
|
|
131
|
+
body: string; // raw message text or task description
|
|
132
|
+
threadRootId?: string;
|
|
133
|
+
noReply?: boolean;
|
|
134
|
+
mediaFields?: Record<string, string | undefined>;
|
|
135
|
+
};
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const PRLL_SESSION_PREFIX = "agent:main:parall:";
|
|
2
|
+
|
|
3
|
+
/** Build OpenClaw session key from Parall account, chat type, and chat ID. */
|
|
4
|
+
export function buildSessionKey(accountId: string, chatType: string, chatId: string): string {
|
|
5
|
+
return `${PRLL_SESSION_PREFIX}${accountId}:${chatType}:${chatId}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract account ID from an OpenClaw session key.
|
|
10
|
+
* Format: "agent:main:parall:{accountId}:{type}:{chatId}"
|
|
11
|
+
*/
|
|
12
|
+
export function extractAccountIdFromSessionKey(sessionKey: string | undefined): string | undefined {
|
|
13
|
+
if (!sessionKey?.startsWith(PRLL_SESSION_PREFIX)) return undefined;
|
|
14
|
+
const rest = sessionKey.slice(PRLL_SESSION_PREFIX.length);
|
|
15
|
+
const firstColon = rest.indexOf(":");
|
|
16
|
+
if (firstColon < 0) return undefined;
|
|
17
|
+
return rest.slice(0, firstColon) || undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract chat ID from an OpenClaw session key.
|
|
22
|
+
* Format: "agent:main:parall:{accountId}:{type}:{chatId}"
|
|
23
|
+
*/
|
|
24
|
+
export function extractChatIdFromSessionKey(sessionKey: string | undefined): string | undefined {
|
|
25
|
+
if (!sessionKey?.startsWith(PRLL_SESSION_PREFIX)) return undefined;
|
|
26
|
+
const rest = sessionKey.slice(PRLL_SESSION_PREFIX.length);
|
|
27
|
+
// Skip accountId and chatType segments
|
|
28
|
+
const parts = rest.split(":");
|
|
29
|
+
if (parts.length < 3) return undefined;
|
|
30
|
+
return parts.slice(2).join(":") || undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Build the single orchestrator session key for all Parall events. */
|
|
34
|
+
export function buildOrchestratorSessionKey(accountId: string): string {
|
|
35
|
+
return `${PRLL_SESSION_PREFIX}${accountId}:orchestrator`;
|
|
36
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type ParallChannelConfig = {
|
|
2
|
+
enabled?: boolean;
|
|
3
|
+
/** Parall server URL, e.g. "https://parall.example.com" */
|
|
4
|
+
parall_url: string;
|
|
5
|
+
/** Agent API key (agk_xxx) */
|
|
6
|
+
api_key: string;
|
|
7
|
+
/** Organization ID */
|
|
8
|
+
org_id: string;
|
|
9
|
+
/** WS Gateway URL (defaults to parall_url with ws:// scheme + /ws path) */
|
|
10
|
+
ws_url?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ResolvedParallAccount = {
|
|
14
|
+
accountId: string;
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
configured: boolean;
|
|
17
|
+
config: ParallChannelConfig;
|
|
18
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
type Logger = {
|
|
5
|
+
info?: (message: string) => void;
|
|
6
|
+
warn?: (message: string) => void;
|
|
7
|
+
error?: (message: string) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type StartWikiHelperParams = {
|
|
11
|
+
accountId: string;
|
|
12
|
+
parallUrl: string;
|
|
13
|
+
apiKey: string;
|
|
14
|
+
orgId: string;
|
|
15
|
+
agentId: string;
|
|
16
|
+
stateDir: string;
|
|
17
|
+
log?: Logger;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type WikiHelperRuntime = {
|
|
21
|
+
mountRoot: string;
|
|
22
|
+
stop: () => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DEFAULT_SYNC_TIMEOUT_MS = 30_000;
|
|
26
|
+
const DEFAULT_WATCH_INTERVAL_SEC = 30;
|
|
27
|
+
|
|
28
|
+
function isCommandMissing(error: unknown): boolean {
|
|
29
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveMountRoot(stateDir: string): string {
|
|
33
|
+
return process.env.PRLL_WIKI_MOUNT_ROOT?.trim() || path.join(stateDir, "workspace");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveWatchIntervalSec(): number {
|
|
37
|
+
const raw = process.env.PRLL_WIKI_REFRESH_INTERVAL_SEC?.trim();
|
|
38
|
+
if (!raw) {
|
|
39
|
+
return DEFAULT_WATCH_INTERVAL_SEC;
|
|
40
|
+
}
|
|
41
|
+
const parsed = Number.parseInt(raw, 10);
|
|
42
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_WATCH_INTERVAL_SEC;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildWikiHelperEnv(params: StartWikiHelperParams, mountRoot: string): NodeJS.ProcessEnv {
|
|
46
|
+
return {
|
|
47
|
+
...process.env,
|
|
48
|
+
PRLL_API_URL: params.parallUrl,
|
|
49
|
+
PRLL_API_KEY: params.apiKey,
|
|
50
|
+
PRLL_ORG_ID: params.orgId,
|
|
51
|
+
PRLL_AGENT_ID: params.agentId,
|
|
52
|
+
PRLL_WIKI_MOUNT_ROOT: mountRoot,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function runWikiSync(params: StartWikiHelperParams, env: NodeJS.ProcessEnv): Promise<"ok" | "missing" | "failed"> {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
let timedOut = false;
|
|
59
|
+
let settled = false;
|
|
60
|
+
|
|
61
|
+
const child = spawn("parall", ["wiki", "sync"], {
|
|
62
|
+
env,
|
|
63
|
+
stdio: "ignore",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const settle = (value: "ok" | "missing" | "failed") => {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
resolve(value);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
timedOut = true;
|
|
74
|
+
params.log?.warn?.(`parall[${params.accountId}]: parall sync timed out after ${DEFAULT_SYNC_TIMEOUT_MS}ms`);
|
|
75
|
+
child.kill("SIGTERM");
|
|
76
|
+
setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
|
|
77
|
+
}, DEFAULT_SYNC_TIMEOUT_MS);
|
|
78
|
+
timer.unref();
|
|
79
|
+
|
|
80
|
+
child.on("error", (error) => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
if (isCommandMissing(error)) {
|
|
83
|
+
params.log?.warn?.(`parall[${params.accountId}]: parall not installed, skipping wiki auto-sync/watch`);
|
|
84
|
+
settle("missing");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
params.log?.warn?.(`parall[${params.accountId}]: parall sync failed to start: ${String(error)}`);
|
|
88
|
+
settle("failed");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
child.on("exit", (code, signal) => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
if (timedOut) {
|
|
94
|
+
settle("failed");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (code === 0) {
|
|
98
|
+
params.log?.info?.(`parall[${params.accountId}]: parall sync completed`);
|
|
99
|
+
settle("ok");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
params.log?.warn?.(
|
|
103
|
+
`parall[${params.accountId}]: parall sync exited with code=${code ?? "null"} signal=${signal ?? "null"}`,
|
|
104
|
+
);
|
|
105
|
+
settle("failed");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function startWikiHelper(params: StartWikiHelperParams): Promise<WikiHelperRuntime> {
|
|
111
|
+
const mountRoot = resolveMountRoot(params.stateDir);
|
|
112
|
+
const env = buildWikiHelperEnv(params, mountRoot);
|
|
113
|
+
|
|
114
|
+
const syncResult = await runWikiSync(params, env);
|
|
115
|
+
if (syncResult === "missing") {
|
|
116
|
+
return {
|
|
117
|
+
mountRoot,
|
|
118
|
+
stop() {},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const intervalSec = resolveWatchIntervalSec();
|
|
123
|
+
let stopped = false;
|
|
124
|
+
const watch = spawn("parall", ["wiki", "watch", "--interval-sec", String(intervalSec)], {
|
|
125
|
+
env,
|
|
126
|
+
stdio: "ignore",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
watch.on("error", (error) => {
|
|
130
|
+
if (stopped) return;
|
|
131
|
+
if (isCommandMissing(error)) {
|
|
132
|
+
params.log?.warn?.(`parall[${params.accountId}]: parall disappeared before watch could start`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
params.log?.warn?.(`parall[${params.accountId}]: parall watch failed: ${String(error)}`);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
watch.on("exit", (code, signal) => {
|
|
139
|
+
if (stopped) return;
|
|
140
|
+
params.log?.warn?.(
|
|
141
|
+
`parall[${params.accountId}]: parall watch exited with code=${code ?? "null"} signal=${signal ?? "null"}`,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
params.log?.info?.(`parall[${params.accountId}]: parall watch started (interval=${intervalSec}s)`);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
mountRoot,
|
|
149
|
+
stop() {
|
|
150
|
+
stopped = true;
|
|
151
|
+
if (watch.exitCode === null && watch.signalCode === null) {
|
|
152
|
+
watch.kill("SIGTERM");
|
|
153
|
+
setTimeout(() => watch.kill("SIGKILL"), 5_000).unref();
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|