mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:20:43 +00:00
feat(acp): add conversation binds for message channels
This commit is contained in:
@@ -116,7 +116,7 @@ type FakeBinding = {
|
||||
targetSessionKey: string;
|
||||
targetKind: "subagent" | "session";
|
||||
conversation: {
|
||||
channel: "discord" | "matrix" | "telegram" | "feishu";
|
||||
channel: "discord" | "matrix" | "telegram" | "feishu" | "bluebubbles" | "imessage";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
@@ -241,7 +241,7 @@ function createSessionBindingCapabilities() {
|
||||
type AcpBindInput = {
|
||||
targetSessionKey: string;
|
||||
conversation: {
|
||||
channel?: "discord" | "matrix" | "telegram" | "feishu";
|
||||
channel?: "discord" | "matrix" | "telegram" | "feishu" | "bluebubbles" | "imessage";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
@@ -279,11 +279,23 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
}
|
||||
: {
|
||||
channel: "telegram" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
};
|
||||
: channel === "bluebubbles"
|
||||
? {
|
||||
channel: "bluebubbles" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
}
|
||||
: channel === "imessage"
|
||||
? {
|
||||
channel: "imessage" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
}
|
||||
: {
|
||||
channel: "telegram" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
};
|
||||
return createSessionBinding({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
conversation,
|
||||
@@ -409,6 +421,38 @@ async function runFeishuDmAcpCommand(commandBody: string, cfg: OpenClawConfig =
|
||||
return handleAcpCommand(createFeishuDmParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
function createBlueBubblesDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "bluebubbles",
|
||||
Surface: "bluebubbles",
|
||||
OriginatingChannel: "bluebubbles",
|
||||
OriginatingTo: "bluebubbles:+15555550123",
|
||||
AccountId: "default",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
async function runBlueBubblesDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(createBlueBubblesDmParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
function createIMessageDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "imessage",
|
||||
Surface: "imessage",
|
||||
OriginatingChannel: "imessage",
|
||||
OriginatingTo: "imessage:+15555550123",
|
||||
AccountId: "default",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
async function runIMessageDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(createIMessageDmParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
async function runInternalAcpCommand(params: {
|
||||
commandBody: string;
|
||||
scopes: string[];
|
||||
@@ -742,6 +786,66 @@ describe("/acp command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("binds the current Discord channel with --bind here without creating a child thread", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await runDiscordAcpCommand("/acp spawn codex --bind here", cfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("Bound this channel to");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "parent-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds BlueBubbles DMs with --bind here", async () => {
|
||||
const result = await runBlueBubblesDmAcpCommand("/acp spawn codex --bind here");
|
||||
|
||||
expect(result?.reply?.text).toContain("Bound this conversation to");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550123",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds iMessage DMs with --bind here", async () => {
|
||||
const result = await runIMessageDmAcpCommand("/acp spawn codex --bind here");
|
||||
|
||||
expect(result?.reply?.text).toContain("Bound this conversation to");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "imessage",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550123",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds Telegram topic ACP spawns to full conversation ids", async () => {
|
||||
const result = await runTelegramAcpCommand("/acp spawn codex --thread here");
|
||||
|
||||
@@ -859,6 +963,14 @@ describe("/acp command", () => {
|
||||
expect(hoisted.ensureSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects mixing --thread and --bind on the same /acp spawn", async () => {
|
||||
const result = await runDiscordAcpCommand("/acp spawn codex --thread here --bind here");
|
||||
|
||||
expect(result?.reply?.text).toContain("Use either --thread or --bind");
|
||||
expect(hoisted.ensureSessionMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects thread-bound ACP spawn when spawnAcpSessions is disabled", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
|
||||
@@ -190,6 +190,80 @@ describe("commands-acp context", () => {
|
||||
expect(resolveAcpCommandParentConversationId(params)).toBe("!room:example.org");
|
||||
});
|
||||
|
||||
it("resolves BlueBubbles DM conversation ids from current targets", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "bluebubbles",
|
||||
Surface: "bluebubbles",
|
||||
OriginatingChannel: "bluebubbles",
|
||||
OriginatingTo: "bluebubbles:+15555550123",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
threadId: undefined,
|
||||
conversationId: "+15555550123",
|
||||
parentConversationId: undefined,
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("+15555550123");
|
||||
});
|
||||
|
||||
it("resolves BlueBubbles group conversation ids from explicit chat targets", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "bluebubbles",
|
||||
Surface: "bluebubbles",
|
||||
OriginatingChannel: "bluebubbles",
|
||||
OriginatingTo: "bluebubbles:chat_guid:iMessage;+;chat123",
|
||||
AccountId: "work",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "bluebubbles",
|
||||
accountId: "work",
|
||||
threadId: undefined,
|
||||
conversationId: "iMessage;+;chat123",
|
||||
parentConversationId: undefined,
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("iMessage;+;chat123");
|
||||
});
|
||||
|
||||
it("resolves iMessage DM conversation ids from current targets", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "imessage",
|
||||
Surface: "imessage",
|
||||
OriginatingChannel: "imessage",
|
||||
OriginatingTo: "imessage:+15555550123",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "imessage",
|
||||
accountId: "default",
|
||||
threadId: undefined,
|
||||
conversationId: "+15555550123",
|
||||
parentConversationId: undefined,
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("+15555550123");
|
||||
});
|
||||
|
||||
it("resolves iMessage group conversation ids from chat_id targets", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "imessage",
|
||||
Surface: "imessage",
|
||||
OriginatingChannel: "imessage",
|
||||
OriginatingTo: "chat_id:12345",
|
||||
AccountId: "work",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "imessage",
|
||||
accountId: "work",
|
||||
threadId: undefined,
|
||||
conversationId: "12345",
|
||||
parentConversationId: undefined,
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("12345");
|
||||
});
|
||||
|
||||
it("builds Feishu topic conversation ids from chat target + root message id", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "feishu",
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
|
||||
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
|
||||
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
|
||||
import { resolveBlueBubblesConversationIdFromTarget } from "../../../plugin-sdk/bluebubbles.js";
|
||||
import {
|
||||
buildFeishuConversationId,
|
||||
parseFeishuDirectConversationId,
|
||||
parseFeishuTargetId,
|
||||
} from "../../../plugin-sdk/feishu.js";
|
||||
import { resolveIMessageConversationIdFromTarget } from "../../../plugin-sdk/imessage-core.js";
|
||||
import { parseAgentSessionKey } from "../../../routing/session-key.js";
|
||||
import type { HandleCommandsParams } from "../commands-types.js";
|
||||
import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js";
|
||||
@@ -160,6 +162,20 @@ export function resolveAcpCommandConversationId(params: HandleCommandsParams): s
|
||||
parseFeishuDirectConversationId(params.ctx.To)
|
||||
);
|
||||
}
|
||||
if (channel === "bluebubbles") {
|
||||
return (
|
||||
resolveBlueBubblesConversationIdFromTarget(params.ctx.OriginatingTo ?? "") ??
|
||||
resolveBlueBubblesConversationIdFromTarget(params.command.to ?? "") ??
|
||||
resolveBlueBubblesConversationIdFromTarget(params.ctx.To ?? "")
|
||||
);
|
||||
}
|
||||
if (channel === "imessage") {
|
||||
return (
|
||||
resolveIMessageConversationIdFromTarget(params.ctx.OriginatingTo ?? "") ??
|
||||
resolveIMessageConversationIdFromTarget(params.command.to ?? "") ??
|
||||
resolveIMessageConversationIdFromTarget(params.ctx.To ?? "")
|
||||
);
|
||||
}
|
||||
return resolveConversationIdFromTargets({
|
||||
threadId: params.ctx.MessageThreadId,
|
||||
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
resolveAcpCommandAccountId,
|
||||
resolveAcpCommandBindingContext,
|
||||
resolveAcpCommandConversationId,
|
||||
resolveAcpCommandThreadId,
|
||||
} from "./context.js";
|
||||
import {
|
||||
ACP_STEER_OUTPUT_LIMIT,
|
||||
@@ -47,11 +48,172 @@ import {
|
||||
parseSteerInput,
|
||||
resolveCommandRequestId,
|
||||
stopWithText,
|
||||
type AcpSpawnBindMode,
|
||||
type AcpSpawnThreadMode,
|
||||
withAcpCommandErrorBoundary,
|
||||
} from "./shared.js";
|
||||
import { resolveAcpTargetSessionKey } from "./targets.js";
|
||||
|
||||
function resolveAcpBindingLabelNoun(params: {
|
||||
channel: string;
|
||||
threadId?: string;
|
||||
bindMode: "thread" | "current";
|
||||
}): string {
|
||||
if (params.bindMode === "current") {
|
||||
if (params.channel === "discord" && !params.threadId) {
|
||||
return "channel";
|
||||
}
|
||||
if (
|
||||
params.channel === "telegram" ||
|
||||
params.channel === "bluebubbles" ||
|
||||
params.channel === "imessage"
|
||||
) {
|
||||
return "conversation";
|
||||
}
|
||||
}
|
||||
return params.channel === "telegram" ? "conversation" : "thread";
|
||||
}
|
||||
|
||||
async function bindSpawnedAcpSessionToCurrentConversation(params: {
|
||||
commandParams: HandleCommandsParams;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
bindMode: AcpSpawnBindMode;
|
||||
sessionMeta?: SessionAcpMeta;
|
||||
}): Promise<{ ok: true; binding: SessionBindingRecord } | { ok: false; error: string }> {
|
||||
if (params.bindMode === "off") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "internal: conversation binding is disabled for this spawn",
|
||||
};
|
||||
}
|
||||
|
||||
const bindingContext = resolveAcpCommandBindingContext(params.commandParams);
|
||||
const channel = bindingContext.channel;
|
||||
if (!channel) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "ACP current-conversation binding requires a channel context.",
|
||||
};
|
||||
}
|
||||
|
||||
const accountId = resolveAcpCommandAccountId(params.commandParams);
|
||||
const bindingPolicy = resolveThreadBindingSpawnPolicy({
|
||||
cfg: params.commandParams.cfg,
|
||||
channel,
|
||||
accountId,
|
||||
kind: "acp",
|
||||
});
|
||||
if (!bindingPolicy.enabled) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatThreadBindingDisabledError({
|
||||
channel: bindingPolicy.channel,
|
||||
accountId: bindingPolicy.accountId,
|
||||
kind: "acp",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const bindingService = getSessionBindingService();
|
||||
const capabilities = bindingService.getCapabilities({
|
||||
channel: bindingPolicy.channel,
|
||||
accountId: bindingPolicy.accountId,
|
||||
});
|
||||
if (!capabilities.adapterAvailable || !capabilities.bindSupported) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Conversation bindings are unavailable for ${channel}.`,
|
||||
};
|
||||
}
|
||||
if (!capabilities.placements.includes("current")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Conversation bindings do not support current placement for ${channel}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const currentConversationId = bindingContext.conversationId?.trim() || "";
|
||||
if (!currentConversationId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `--bind here requires running /acp spawn inside an active ${channel} conversation.`,
|
||||
};
|
||||
}
|
||||
|
||||
const senderId = params.commandParams.command.senderId?.trim() || "";
|
||||
const parentConversationId = bindingContext.parentConversationId?.trim() || undefined;
|
||||
const conversationRef = {
|
||||
channel: bindingPolicy.channel,
|
||||
accountId: bindingPolicy.accountId,
|
||||
conversationId: currentConversationId,
|
||||
...(parentConversationId && parentConversationId !== currentConversationId
|
||||
? { parentConversationId }
|
||||
: {}),
|
||||
};
|
||||
const existingBinding = bindingService.resolveByConversation(conversationRef);
|
||||
const boundBy =
|
||||
typeof existingBinding?.metadata?.boundBy === "string"
|
||||
? existingBinding.metadata.boundBy.trim()
|
||||
: "";
|
||||
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Only ${boundBy} can rebind this ${channel === "discord" && !bindingContext.threadId ? "channel" : "conversation"}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const label = params.label || params.agentId;
|
||||
try {
|
||||
const binding = await bindingService.bind({
|
||||
targetSessionKey: params.sessionKey,
|
||||
targetKind: "session",
|
||||
conversation: conversationRef,
|
||||
placement: "current",
|
||||
metadata: {
|
||||
threadName: resolveThreadBindingThreadName({
|
||||
agentId: params.agentId,
|
||||
label,
|
||||
}),
|
||||
agentId: params.agentId,
|
||||
label,
|
||||
boundBy: senderId || "unknown",
|
||||
introText: resolveThreadBindingIntroText({
|
||||
agentId: params.agentId,
|
||||
label,
|
||||
idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({
|
||||
cfg: params.commandParams.cfg,
|
||||
channel: bindingPolicy.channel,
|
||||
accountId: bindingPolicy.accountId,
|
||||
}),
|
||||
maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({
|
||||
cfg: params.commandParams.cfg,
|
||||
channel: bindingPolicy.channel,
|
||||
accountId: bindingPolicy.accountId,
|
||||
}),
|
||||
sessionCwd: resolveAcpSessionCwd(params.sessionMeta),
|
||||
sessionDetails: resolveAcpThreadSessionDetailLines({
|
||||
sessionKey: params.sessionKey,
|
||||
meta: params.sessionMeta,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
binding,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
message || `Failed to bind the current ${channel} conversation to the new ACP session.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function bindSpawnedAcpSessionToThread(params: {
|
||||
commandParams: HandleCommandsParams;
|
||||
sessionKey: string;
|
||||
@@ -308,7 +470,26 @@ export async function handleAcpSpawnAction(
|
||||
}
|
||||
|
||||
let binding: SessionBindingRecord | null = null;
|
||||
if (spawn.thread !== "off") {
|
||||
if (spawn.bind !== "off") {
|
||||
const bound = await bindSpawnedAcpSessionToCurrentConversation({
|
||||
commandParams: params,
|
||||
sessionKey,
|
||||
agentId: spawn.agentId,
|
||||
label: spawn.label,
|
||||
bindMode: spawn.bind,
|
||||
sessionMeta: initializedMeta,
|
||||
});
|
||||
if (!bound.ok) {
|
||||
await cleanupFailedSpawn({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
shouldDeleteSession: true,
|
||||
initializedRuntime,
|
||||
});
|
||||
return stopWithText(`⚠️ ${bound.error}`);
|
||||
}
|
||||
binding = bound.binding;
|
||||
} else if (spawn.thread !== "off") {
|
||||
const bound = await bindSpawnedAcpSessionToThread({
|
||||
commandParams: params,
|
||||
sessionKey,
|
||||
@@ -355,14 +536,20 @@ export async function handleAcpSpawnAction(
|
||||
if (binding) {
|
||||
const currentConversationId = resolveAcpCommandConversationId(params)?.trim() || "";
|
||||
const boundConversationId = binding.conversation.conversationId.trim();
|
||||
const placementLabel = binding.conversation.channel === "telegram" ? "conversation" : "thread";
|
||||
const placementLabel = resolveAcpBindingLabelNoun({
|
||||
channel: binding.conversation.channel,
|
||||
threadId: resolveAcpCommandThreadId(params),
|
||||
bindMode: spawn.bind !== "off" ? "current" : "thread",
|
||||
});
|
||||
if (currentConversationId && boundConversationId === currentConversationId) {
|
||||
parts.push(`Bound this ${placementLabel} to ${sessionKey}.`);
|
||||
} else {
|
||||
parts.push(`Created ${placementLabel} ${boundConversationId} and bound it to ${sessionKey}.`);
|
||||
}
|
||||
} else {
|
||||
parts.push("Session is unbound (use /focus <session-key> to bind this thread/conversation).");
|
||||
parts.push(
|
||||
"Session is unbound (use /acp spawn ... --bind here to bind this conversation, or /focus <session-key> where supported).",
|
||||
);
|
||||
}
|
||||
|
||||
const dispatchNote = resolveAcpDispatchPolicyMessage(params.cfg);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseSteerInput } from "./shared.js";
|
||||
import { parseSpawnInput, parseSteerInput } from "./shared.js";
|
||||
|
||||
describe("parseSteerInput", () => {
|
||||
it("preserves non-option instruction tokens while normalizing unicode-dash flags", () => {
|
||||
@@ -20,3 +20,22 @@ describe("parseSteerInput", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSpawnInput", () => {
|
||||
it("rejects mixing --thread and --bind on the same spawn", () => {
|
||||
const parsed = parseSpawnInput(
|
||||
{
|
||||
cfg: {},
|
||||
ctx: {},
|
||||
command: {},
|
||||
} as never,
|
||||
["codex", "--thread", "here", "--bind", "here"],
|
||||
);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
ok: false,
|
||||
error:
|
||||
"Use either --thread or --bind for /acp spawn, not both. Usage: /acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--bind here|off] [--cwd <path>] [--label <label>].",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export { resolveAcpInstallCommandHint, resolveConfiguredAcpBackendId } from "./i
|
||||
|
||||
export const COMMAND = "/acp";
|
||||
export const ACP_SPAWN_USAGE =
|
||||
"Usage: /acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>].";
|
||||
"Usage: /acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--bind here|off] [--cwd <path>] [--label <label>].";
|
||||
export const ACP_STEER_USAGE =
|
||||
"Usage: /acp steer [--session <session-key|session-id|session-label>] <instruction>";
|
||||
export const ACP_SET_MODE_USAGE =
|
||||
@@ -55,11 +55,13 @@ export type AcpAction =
|
||||
| "help";
|
||||
|
||||
export type AcpSpawnThreadMode = "auto" | "here" | "off";
|
||||
export type AcpSpawnBindMode = "here" | "off";
|
||||
|
||||
export type ParsedSpawnInput = {
|
||||
agentId: string;
|
||||
mode: AcpRuntimeSessionMode;
|
||||
thread: AcpSpawnThreadMode;
|
||||
bind: AcpSpawnBindMode;
|
||||
cwd?: string;
|
||||
label?: string;
|
||||
};
|
||||
@@ -186,6 +188,8 @@ export function parseSpawnInput(
|
||||
const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token));
|
||||
let mode: AcpRuntimeSessionMode = "persistent";
|
||||
let thread = resolveDefaultSpawnThreadMode(params);
|
||||
let sawThreadOption = false;
|
||||
let bind: AcpSpawnBindMode = "off";
|
||||
let cwd: string | undefined;
|
||||
let label: string | undefined;
|
||||
let rawAgentId: string | undefined;
|
||||
@@ -210,6 +214,23 @@ export function parseSpawnInput(
|
||||
continue;
|
||||
}
|
||||
|
||||
const bindOption = readOptionValue({ tokens: normalizedTokens, index: i, flag: "--bind" });
|
||||
if (bindOption.matched) {
|
||||
if (bindOption.error) {
|
||||
return { ok: false, error: `${bindOption.error}. ${ACP_SPAWN_USAGE}` };
|
||||
}
|
||||
const raw = bindOption.value?.trim().toLowerCase();
|
||||
if (raw !== "here" && raw !== "off") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid --bind value "${bindOption.value}". Use here or off.`,
|
||||
};
|
||||
}
|
||||
bind = raw;
|
||||
i = bindOption.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const threadOption = readOptionValue({
|
||||
tokens: normalizedTokens,
|
||||
index: i,
|
||||
@@ -227,6 +248,7 @@ export function parseSpawnInput(
|
||||
};
|
||||
}
|
||||
thread = raw;
|
||||
sawThreadOption = true;
|
||||
i = threadOption.nextIndex;
|
||||
continue;
|
||||
}
|
||||
@@ -279,6 +301,15 @@ export function parseSpawnInput(
|
||||
};
|
||||
}
|
||||
const normalizedAgentId = normalizeAgentId(selectedAgent);
|
||||
if (bind !== "off" && !sawThreadOption) {
|
||||
thread = "off";
|
||||
}
|
||||
if (thread !== "off" && bind !== "off") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Use either --thread or --bind for /acp spawn, not both. ${ACP_SPAWN_USAGE}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -286,6 +317,7 @@ export function parseSpawnInput(
|
||||
agentId: normalizedAgentId,
|
||||
mode,
|
||||
thread,
|
||||
bind,
|
||||
cwd,
|
||||
label: label || undefined,
|
||||
},
|
||||
@@ -405,7 +437,7 @@ export function resolveAcpHelpText(): string {
|
||||
return [
|
||||
"ACP commands:",
|
||||
"-----",
|
||||
"/acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>]",
|
||||
"/acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--bind here|off] [--cwd <path>] [--label <label>]",
|
||||
"/acp cancel [session-key|session-id|session-label]",
|
||||
"/acp steer [--session <session-key|session-id|session-label>] <instruction>",
|
||||
"/acp close [session-key|session-id|session-label]",
|
||||
@@ -423,6 +455,7 @@ export function resolveAcpHelpText(): string {
|
||||
"",
|
||||
"Notes:",
|
||||
"- /acp spawn harness-id is an ACP runtime harness alias (for example codex), not an OpenClaw agents.list id.",
|
||||
"- Use --bind here to pin the current conversation to the ACP session without creating a child thread.",
|
||||
"- /focus and /unfocus also work with ACP session keys.",
|
||||
"- ACP dispatch of normal thread messages is controlled by acp.dispatch.enabled.",
|
||||
].join("\n");
|
||||
|
||||
Reference in New Issue
Block a user