feat: simplify thread-bound session spawning

This commit is contained in:
Peter Steinberger
2026-05-02 04:52:17 +01:00
parent 5ac0ff1812
commit 8612af754b
53 changed files with 892 additions and 219 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Plugins/ClawHub: persist ClawPack digest metadata on ClawHub plugin install and update records so registry refreshes and download verification can reuse stored artifact facts. Thanks @vincentkoc.
- Plugins/ClawHub: allow official bundled-plugin cutovers to prefer ClawHub installs with npm fallback only when the ClawHub package or version is absent. Thanks @vincentkoc.
- Plugins/Crestodian: add ClawHub plugin search plus Crestodian plugin list/search/install/uninstall operations, with approval and audit coverage for install and uninstall.
- Channels/thread bindings: replace split subagent/ACP thread-spawn toggles with `threadBindings.spawnSessions`, default thread-bound spawns on, and let `openclaw doctor --fix` migrate the legacy keys.
- Providers/OpenAI: add `extraBody`/`extra_body` passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as `lang` in `/audio/speech` requests. Fixes #39900. Thanks @R3NK0R.
- Dependencies: refresh workspace dependency pins, including TypeBox 1.1.37, AWS SDK 3.1041.0, Microsoft Teams 2.0.9, and Marked 18.0.3. Thanks @mariozechner, @aws, and @microsoft.
- Discord/channels: add reusable message-channel access groups plus Discord channel-audience DM authorization, so allowlists can reference `accessGroup:<name>` across channel auth paths. (#75813)

View File

@@ -1,4 +1,4 @@
94f7879b0771e81973c0749c719c19283fdc26e0e42fe6536f8ee563be6a44e5 config-baseline.json
a38ea77d2f0f0188f14ce0e3a8a564ff80e51415849359042f51921eb01ec2d9 config-baseline.core.json
eab8a85eefa2792fb8b98a07698e5ec31ff0b6f8af6222767e8049dcc5c4f529 config-baseline.channel.json
6bd6c72b17801072b2d3285c82f4c21adcc95f0edffc1e6f64e767d0a07b678f config-baseline.plugin.json
d0b1fc318d2f737c91c21ffffae2fe12197b4ba6d49859c4786ecbc586cf5a82 config-baseline.json
3f9c52903905d82d4b4ca9dbda530cac2e059870b08c69965099ebcd09a270a3 config-baseline.core.json
f42329d45c095881bd226bdb192c235980658fd250606d0c0badc2b12f12f5d3 config-baseline.channel.json
af71b84b2411d8ccabcc6e09de0ee41f8212ff9869a6677698b6e7e3afdfaa47 config-baseline.plugin.json

View File

@@ -738,7 +738,8 @@ Default slash command settings:
enabled: true,
idleHours: 24,
maxAgeHours: 0,
spawnSubagentSessions: false, // opt-in
spawnSessions: true,
defaultSpawnContext: "fork",
},
},
},
@@ -749,8 +750,9 @@ Default slash command settings:
- `session.threadBindings.*` sets global defaults.
- `channels.discord.threadBindings.*` overrides Discord behavior.
- `spawnSubagentSessions` must be true to auto-create/bind threads for `sessions_spawn({ thread: true })`.
- `spawnAcpSessions` must be true to auto-create/bind threads for ACP (`/acp spawn ... --thread ...` or `sessions_spawn({ runtime: "acp", thread: true })`).
- `spawnSessions` controls auto-create/bind threads for `sessions_spawn({ thread: true })` and ACP thread spawns. Default: `true`.
- `defaultSpawnContext` controls native subagent context for thread-bound spawns. Default: `"fork"`.
- Deprecated `spawnSubagentSessions`/`spawnAcpSessions` keys are migrated by `openclaw doctor --fix`.
- If thread bindings are disabled for an account, `/focus` and related thread binding operations are unavailable.
See [Sub-agents](/tools/subagents), [ACP Agents](/tools/acp-agents), and [Configuration Reference](/gateway/configuration-reference).
@@ -816,7 +818,7 @@ Default slash command settings:
- `/acp spawn codex --bind here` binds the current channel or thread in place and keeps future messages on the same ACP session. Thread messages inherit the parent channel binding.
- In a bound channel or thread, `/new` and `/reset` reset the same ACP session in place. Temporary thread bindings can override target resolution while active.
- `spawnAcpSessions` is only required when OpenClaw needs to create/bind a child thread via `--thread auto|here`.
- `spawnSessions` gates child thread creation/binding via `--thread auto|here`.
See [ACP Agents](/tools/acp-agents) for binding behavior details.

View File

@@ -530,7 +530,7 @@ Explicit conversation bindings always win over `sessionScope`, so bound rooms an
- Message-tool sends auto-inherit the current Matrix thread when targeting the same room (or the same DM user target), unless an explicit `threadId` is provided.
- DM user-target reuse only kicks in when the current session metadata proves the same DM peer on the same Matrix account; otherwise OpenClaw falls back to normal user-scoped routing.
- `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and thread-bound `/acp spawn` all work in Matrix rooms and DMs.
- Top-level `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions: true`.
- Top-level `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSessions` is enabled.
- Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that thread in place.
When OpenClaw detects a Matrix DM room colliding with another DM room on the same shared session, it posts a one-time `m.notice` in that room pointing to the `/focus` escape hatch and suggesting a `dm.sessionScope` change. The notice only appears when thread bindings are enabled.
@@ -550,7 +550,7 @@ Fast operator flow:
Notes:
- `--bind here` does not create a child Matrix thread.
- `threadBindings.spawnAcpSessions` is only required for `/acp spawn --thread auto|here`, where OpenClaw needs to create or bind a child Matrix thread.
- `threadBindings.spawnSessions` gates `/acp spawn --thread auto|here`, where OpenClaw needs to create or bind a child Matrix thread.
### Thread binding config
@@ -559,13 +559,13 @@ Matrix inherits global defaults from `session.threadBindings`, and also supports
- `threadBindings.enabled`
- `threadBindings.idleHours`
- `threadBindings.maxAgeHours`
- `threadBindings.spawnSubagentSessions`
- `threadBindings.spawnAcpSessions`
- `threadBindings.spawnSessions`
- `threadBindings.defaultSpawnContext`
Matrix thread-bound spawn flags are opt-in:
Matrix thread-bound session spawns default on:
- Set `threadBindings.spawnSubagentSessions: true` to allow top-level `/focus` to create and bind new Matrix threads.
- Set `threadBindings.spawnAcpSessions: true` to allow `/acp spawn --thread auto|here` to bind ACP sessions to Matrix threads.
- Set `threadBindings.spawnSessions: false` to block top-level `/focus` and `/acp spawn --thread auto|here` from creating/binding Matrix threads.
- Set `threadBindings.defaultSpawnContext: "isolated"` when native subagent thread spawns should not fork the parent transcript.
## Reactions

View File

@@ -540,7 +540,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
**Persistent ACP topic binding**: Forum topics can pin ACP harness sessions through top-level typed ACP bindings (`bindings[]` with `type: "acp"` and `match.channel: "telegram"`, `peer.kind: "group"`, and a topic-qualified id like `-1001234567890:topic:42`). Currently scoped to forum topics in groups/supergroups. See [ACP Agents](/tools/acp-agents).
**Thread-bound ACP spawn from chat**: `/acp spawn <agent> --thread here|auto` binds the current topic to a new ACP session; follow-ups route there directly. OpenClaw pins the spawn confirmation in-topic. Requires `channels.telegram.threadBindings.spawnAcpSessions=true`.
**Thread-bound ACP spawn from chat**: `/acp spawn <agent> --thread here|auto` binds the current topic to a new ACP session; follow-ups route there directly. OpenClaw pins the spawn confirmation in-topic. Requires `channels.telegram.threadBindings.spawnSessions` to remain enabled (default: `true`).
Template context exposes `MessageThreadId` and `IsForum`. DM chats with `message_thread_id` keep DM routing but use thread-aware session keys.

View File

@@ -143,6 +143,8 @@ Key options:
- `sandbox: "require"` to enforce sandboxing on the child.
- `context: "fork"` for native sub-agents when the child needs the current
requester transcript; omit it or use `context: "isolated"` for a clean child.
Thread-bound native sub-agents default to `context: "fork"` unless
`threadBindings.defaultSpawnContext` says otherwise.
Default leaf sub-agents do not get session tools. When
`maxSpawnDepth >= 2`, depth-1 orchestrator sub-agents additionally receive

View File

@@ -1229,6 +1229,8 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`)
- `idleHours`: default inactivity auto-unfocus in hours (`0` disables; providers can override)
- `maxAgeHours`: default hard max age in hours (`0` disables; providers can override)
- `spawnSessions`: default gate for creating thread-bound work sessions from `sessions_spawn` and ACP thread spawns. Defaults to `true` when thread bindings are enabled; providers/accounts can override.
- `defaultSpawnContext`: default native subagent context for thread-bound spawns (`"fork"` or `"isolated"`). Defaults to `"fork"`.
</Accordion>

View File

@@ -285,7 +285,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
enabled: true,
idleHours: 24,
maxAgeHours: 0,
spawnSubagentSessions: false, // opt-in for sessions_spawn({ thread: true })
spawnSessions: true,
defaultSpawnContext: "fork",
},
voice: {
enabled: true,
@@ -336,7 +337,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and bound delivery/routing)
- `idleHours`: Discord override for inactivity auto-unfocus in hours (`0` disables)
- `maxAgeHours`: Discord override for hard max age in hours (`0` disables)
- `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding
- `spawnSessions`: switch for `sessions_spawn({ thread: true })` and ACP thread-spawn auto thread creation/binding (default: `true`)
- `defaultSpawnContext`: native subagent context for thread-bound spawns (`"fork"` by default)
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for channels and threads (use channel/thread id in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#channel-specific-settings).
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + LLM + TTS overrides. Text-only Discord configs leave voice off by default; set `channels.discord.voice.enabled=true` to opt in.

View File

@@ -215,7 +215,7 @@ lives on the [First-run FAQ](/help/faq-first-run).
- Global defaults: `session.threadBindings.enabled`, `session.threadBindings.idleHours`, `session.threadBindings.maxAgeHours`.
- Discord overrides: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`.
- Auto-bind on spawn: set `channels.discord.threadBindings.spawnSubagentSessions: true`.
- Auto-bind on spawn: `channels.discord.threadBindings.spawnSessions` defaults to `true`; set it to `false` to disable thread-bound session spawns.
Docs: [Sub-agents](/tools/subagents), [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), [Slash commands](/tools/slash-commands).

View File

@@ -109,7 +109,7 @@ Thread binding config is channel-adapter specific. Example for Discord:
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
spawnSessions: true,
},
},
},
@@ -118,7 +118,7 @@ Thread binding config is channel-adapter specific. Example for Discord:
If thread-bound ACP spawn does not work, verify the adapter feature flag first:
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
- Discord: `channels.discord.threadBindings.spawnSessions=true`
Current-conversation binds do not require child-thread creation. They require an active conversation context and a channel adapter that exposes ACP conversation bindings.

View File

@@ -279,7 +279,7 @@ Examples:
<Accordion title="Binding rules and exclusivity">
- `--bind here` and `--thread ...` are mutually exclusive.
- `--bind here` only works on channels that advertise current-conversation binding; OpenClaw returns a clear unsupported message otherwise. Bindings persist across gateway restarts.
- On Discord, `spawnAcpSessions` is only required when OpenClaw needs to create a child thread for `--thread auto|here` — not for `--bind here`.
- On Discord, `spawnSessions` gates child thread creation for `--thread auto|here` — not `--bind here`.
- If you spawn to a different ACP agent without `--cwd`, OpenClaw inherits the **target agent's** workspace by default. Missing inherited paths (`ENOENT`/`ENOTDIR`) fall back to the backend default; other access errors (e.g. `EACCES`) surface as spawn errors.
- Gateway management commands stay local in bound conversations — `/acp ...` commands are handled by OpenClaw even when normal follow-up text routes to the bound ACP session; `/status` and `/unfocus` also stay local whenever command handling is enabled for that surface.
@@ -297,9 +297,9 @@ Examples:
- `acp.enabled=true`
- `acp.dispatch.enabled` is on by default (set `false` to pause automatic ACP thread dispatch; explicit `sessions_spawn({ runtime: "acp" })` calls still work).
- Channel-adapter ACP thread-spawn flag enabled (adapter-specific):
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
- Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true`
- Channel-adapter thread session spawns enabled (default: `true`):
- Discord: `channels.discord.threadBindings.spawnSessions=true`
- Telegram: `channels.telegram.threadBindings.spawnSessions=true`
Thread binding support is adapter-specific. If the active channel
adapter does not support thread bindings, OpenClaw returns a clear
@@ -592,8 +592,8 @@ Two ways to start an ACP session:
- On non-thread binding surfaces, default behavior is effectively `off`.
- Thread-bound spawn requires channel policy support:
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
- Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true`
- Discord: `channels.discord.threadBindings.spawnSessions=true`
- Telegram: `channels.telegram.threadBindings.spawnSessions=true`
- Use `--bind here` when you want to pin the current conversation without creating a child thread.
</Tab>

View File

@@ -26,8 +26,10 @@ Primary goals:
default. For heavy or repetitive tasks, set a cheaper model for sub-agents
and keep your main agent on a higher-quality model. Configure via
`agents.defaults.subagents.model` or per-agent overrides. When a child
genuinely needs the requester's current transcript, the agent can request
`context: "fork"` on that one spawn.
genuinely needs the requester's current transcript, the agent can request
`context: "fork"` on that one spawn. Thread-bound subagent sessions default
to `context: "fork"` because they branch the current conversation into a
follow-up thread.
</Note>
## Slash command
@@ -179,7 +181,7 @@ session to confirm the effective tool list.
`require` rejects spawn unless the target child runtime is sandboxed.
</ParamField>
<ParamField path="context" type='"isolated" | "fork"' default="isolated">
`fork` branches the requester's current transcript into the child session. Native sub-agents only. Use `fork` only when the child needs the current transcript.
`fork` branches the requester's current transcript into the child session. Native sub-agents only. Thread-bound spawns default to `fork`; non-thread spawns default to `isolated`.
</ParamField>
<Warning>
@@ -203,7 +205,7 @@ persistent thread-bound subagent sessions (`sessions_spawn` with
`channels.discord.threadBindings.enabled`,
`channels.discord.threadBindings.idleHours`,
`channels.discord.threadBindings.maxAgeHours`, and
`channels.discord.threadBindings.spawnSubagentSessions`.
`channels.discord.threadBindings.spawnSessions`.
### Quick flow

View File

@@ -113,13 +113,13 @@ export const discordChannelConfigUiHints = {
label: "Discord Thread Binding Max Age (hours)",
help: "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
},
"threadBindings.spawnSubagentSessions": {
label: "Discord Thread-Bound Subagent Spawn",
help: "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.",
"threadBindings.spawnSessions": {
label: "Discord Thread-Bound Session Spawn",
help: "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel.",
},
"threadBindings.spawnAcpSessions": {
label: "Discord Thread-Bound ACP Spawn",
help: "Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.",
"threadBindings.defaultSpawnContext": {
label: "Discord Thread Spawn Context",
help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".',
},
"ui.components.accentColor": {
label: "Discord Component Accent Color",

View File

@@ -15,7 +15,7 @@ type MockResolvedDiscordAccount = {
config: {
threadBindings?: {
enabled?: boolean;
spawnSubagentSessions?: boolean;
spawnSessions?: boolean;
};
};
};
@@ -26,7 +26,7 @@ const hookMocks = vi.hoisted(() => ({
accountId: params?.accountId?.trim() || "default",
config: {
threadBindings: {
spawnSubagentSessions: true,
spawnSessions: true,
},
},
}),
@@ -54,7 +54,7 @@ function registerHandlersForTest(
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: true,
spawnSessions: true,
},
},
},
@@ -176,7 +176,7 @@ describe("discord subagent hook handlers", () => {
accountId: params?.accountId?.trim() || "default",
config: {
threadBindings: {
spawnSubagentSessions: true,
spawnSessions: true,
},
},
}));
@@ -197,7 +197,7 @@ describe("discord subagent hook handlers", () => {
channels: expect.objectContaining({
discord: expect.objectContaining({
threadBindings: expect.objectContaining({
spawnSubagentSessions: true,
spawnSessions: true,
}),
}),
}),
@@ -220,12 +220,12 @@ describe("discord subagent hook handlers", () => {
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: false,
spawnSessions: false,
},
},
},
},
errorContains: "spawnSubagentSessions=true",
errorContains: "spawnSessions=true",
});
});
@@ -240,7 +240,7 @@ describe("discord subagent hook handlers", () => {
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: true,
spawnSessions: true,
},
},
},
@@ -262,7 +262,7 @@ describe("discord subagent hook handlers", () => {
work: {
threadBindings: {
enabled: true,
spawnSubagentSessions: true,
spawnSessions: true,
},
},
},
@@ -274,16 +274,17 @@ describe("discord subagent hook handlers", () => {
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
});
it("defaults thread-bound subagent spawn to disabled when unset", async () => {
await expectSubagentSpawningError({
config: {
channels: {
discord: {
threadBindings: {},
},
it("defaults thread-bound subagent spawn to enabled when unset", async () => {
const result = await runSubagentSpawning({
channels: {
discord: {
threadBindings: {},
},
},
});
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
});
it("no-ops when thread binding is requested on non-discord channel", async () => {

View File

@@ -1,9 +1,13 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingSpawnPolicy,
} from "openclaw/plugin-sdk/conversation-runtime";
import {
normalizeOptionalLowercaseString,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordAccount } from "./accounts.js";
import {
autoBindSpawnedDiscordSubagent,
listThreadBindingsBySessionKey,
@@ -76,27 +80,6 @@ function normalizeThreadBindingTargetKind(raw?: string): ThreadBindingTargetKind
return undefined;
}
function resolveThreadBindingFlags(api: OpenClawPluginApi, accountId?: string) {
const account = resolveDiscordAccount({
cfg: api.config,
accountId,
});
const baseThreadBindings = api.config.channels?.discord?.threadBindings;
const accountThreadBindings =
api.config.channels?.discord?.accounts?.[account.accountId]?.threadBindings;
return {
enabled:
accountThreadBindings?.enabled ??
baseThreadBindings?.enabled ??
api.config.session?.threadBindings?.enabled ??
true,
spawnSubagentSessions:
accountThreadBindings?.spawnSubagentSessions ??
baseThreadBindings?.spawnSubagentSessions ??
false,
};
}
export async function handleDiscordSubagentSpawning(
api: OpenClawPluginApi,
event: DiscordSubagentSpawningEvent,
@@ -108,19 +91,30 @@ export async function handleDiscordSubagentSpawning(
if (channel !== "discord") {
return undefined;
}
const threadBindingFlags = resolveThreadBindingFlags(api, event.requester?.accountId);
if (!threadBindingFlags.enabled) {
const threadBindingPolicy = resolveThreadBindingSpawnPolicy({
cfg: api.config,
channel: "discord",
accountId: event.requester?.accountId,
kind: "subagent",
});
if (!threadBindingPolicy.enabled) {
return {
status: "error" as const,
error:
"Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).",
error: formatThreadBindingDisabledError({
channel: threadBindingPolicy.channel,
accountId: threadBindingPolicy.accountId,
kind: "subagent",
}),
};
}
if (!threadBindingFlags.spawnSubagentSessions) {
if (!threadBindingPolicy.spawnEnabled) {
return {
status: "error" as const,
error:
"Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable).",
error: formatThreadBindingSpawnDisabledError({
channel: threadBindingPolicy.channel,
accountId: threadBindingPolicy.accountId,
kind: "subagent",
}),
};
}
try {

View File

@@ -8,6 +8,8 @@ const ThreadBindingsSchema = z
enabled: z.boolean().optional(),
idleHours: z.number().optional(),
maxAgeHours: z.number().optional(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
})

View File

@@ -6,7 +6,11 @@ interface LineThreadBindingsConfig {
enabled?: boolean;
idleHours?: number;
maxAgeHours?: number;
spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
}

View File

@@ -26,6 +26,8 @@ const matrixThreadBindingsSchema = z
enabled: z.boolean().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
})

View File

@@ -90,9 +90,8 @@ describe("handleMatrixSubagentSpawning", () => {
getManagerMock.mockReset();
resolveMatrixBaseConfigMock.mockReset();
findMatrixAccountConfigMock.mockReset();
// Default: bindings enabled, spawn enabled
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: true, spawnSubagentSessions: true },
threadBindings: { enabled: true, spawnSessions: true },
});
findMatrixAccountConfigMock.mockReturnValue(undefined);
getCapabilitiesMock.mockReturnValue({
@@ -140,40 +139,46 @@ describe("handleMatrixSubagentSpawning", () => {
});
it("returns error when thread bindings are disabled", async () => {
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: false, spawnSubagentSessions: true },
});
const result = await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent());
const result = await handleMatrixSubagentSpawning(
{
config: {
channels: {
matrix: {
threadBindings: { enabled: false, spawnSessions: true },
},
},
},
} as never,
makeSpawnEvent(),
);
expect(result).toEqual(expect.objectContaining({ status: "error" }));
expect((result as { error?: string }).error).toMatch(/thread bindings are disabled/i);
});
it("returns error when spawnSessions is false", async () => {
const result = await handleMatrixSubagentSpawning(
{
config: {
channels: {
matrix: {
threadBindings: { enabled: true, spawnSessions: false },
},
},
},
} as never,
makeSpawnEvent(),
);
expect(result).toEqual(
expect.objectContaining({
status: "error",
error: expect.stringContaining("thread bindings are disabled"),
error: expect.stringContaining("spawnSessions"),
}),
);
});
it("returns error when spawnSubagentSessions is false", async () => {
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: true, spawnSubagentSessions: false },
});
it("allows thread-bound subagent spawn by default", async () => {
const result = await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent());
expect(result).toEqual(
expect.objectContaining({
status: "error",
error: expect.stringContaining("spawnSubagentSessions"),
}),
);
});
it("returns error when spawnSubagentSessions defaults to false (no config)", async () => {
resolveMatrixBaseConfigMock.mockReturnValue({});
const result = await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent());
expect(result).toEqual(
expect.objectContaining({
status: "error",
error: expect.stringContaining("spawnSubagentSessions"),
}),
);
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
});
it("returns error when requester.to has no room target", async () => {
@@ -295,17 +300,23 @@ describe("handleMatrixSubagentSpawning", () => {
});
it("respects per-account threadBindings override over base config", async () => {
// Base says spawnSubagentSessions=false; account override says true
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: true, spawnSubagentSessions: false },
});
findMatrixAccountConfigMock.mockReturnValue({
threadBindings: { spawnSubagentSessions: true },
});
bindMock.mockResolvedValue({ conversation: {} });
const result = await handleMatrixSubagentSpawning(
fakeApi,
{
config: {
channels: {
matrix: {
threadBindings: { enabled: true, spawnSessions: false },
accounts: {
forge: {
threadBindings: { spawnSessions: true },
},
},
},
},
},
} as never,
makeSpawnEvent({ accountId: "forge" }),
);
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
@@ -322,7 +333,7 @@ describe("matrix subagent hook registration", () => {
listBindingsForAccountMock.mockReset();
listAllBindingsMock.mockReset();
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: true, spawnSubagentSessions: true },
threadBindings: { enabled: true, spawnSessions: true },
});
findMatrixAccountConfigMock.mockReturnValue(undefined);
getCapabilitiesMock.mockReturnValue({
@@ -784,7 +795,7 @@ describe("concurrent spawns across accounts", () => {
resolveMatrixBaseConfigMock.mockReset();
findMatrixAccountConfigMock.mockReset();
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: true, spawnSubagentSessions: true },
threadBindings: { enabled: true, spawnSessions: true },
});
findMatrixAccountConfigMock.mockReturnValue(undefined);
getCapabilitiesMock.mockReturnValue({

View File

@@ -3,9 +3,13 @@ import {
getSessionBindingService,
type SessionBindingRecord,
} from "openclaw/plugin-sdk/conversation-binding-runtime";
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingSpawnPolicy,
} from "openclaw/plugin-sdk/conversation-runtime";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "./account-config.js";
import { resolveMatrixTargetIdentity } from "./target-ids.js";
import {
getMatrixThreadBindingManager,
@@ -76,28 +80,6 @@ function summarizeError(err: unknown): string {
return "error";
}
function resolveThreadBindingFlags(
api: OpenClawPluginApi,
accountId?: string,
): { enabled: boolean; spawnSubagentSessions: boolean } {
const matrix = resolveMatrixBaseConfig(api.config);
const baseThreadBindings = matrix.threadBindings;
const accountThreadBindings = accountId
? findMatrixAccountConfig(api.config, accountId)?.threadBindings
: undefined;
return {
enabled:
accountThreadBindings?.enabled ??
baseThreadBindings?.enabled ??
api.config.session?.threadBindings?.enabled ??
true,
spawnSubagentSessions:
accountThreadBindings?.spawnSubagentSessions ??
baseThreadBindings?.spawnSubagentSessions ??
false,
};
}
function resolveMatrixBindingThreadId(binding: SessionBindingRecord): string | undefined {
const { conversationId, parentConversationId } = binding.conversation;
return parentConversationId && parentConversationId !== conversationId
@@ -136,20 +118,31 @@ export async function handleMatrixSubagentSpawning(
// Falls back to DEFAULT_ACCOUNT_ID so accounts.default.threadBindings.* is
// respected even when the requester omits accountId.
const accountId = normalizeOptionalString(event.requester?.accountId) || DEFAULT_ACCOUNT_ID;
const flags = resolveThreadBindingFlags(api, accountId);
const policy = resolveThreadBindingSpawnPolicy({
cfg: api.config,
channel: "matrix",
accountId,
kind: "subagent",
});
if (!flags.enabled) {
if (!policy.enabled) {
return {
status: "error",
error:
"Matrix thread bindings are disabled (set channels.matrix.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).",
error: formatThreadBindingDisabledError({
channel: policy.channel,
accountId: policy.accountId,
kind: "subagent",
}),
} satisfies SpawningResult;
}
if (!flags.spawnSubagentSessions) {
if (!policy.spawnEnabled) {
return {
status: "error",
error:
"Matrix thread-bound subagent spawns are disabled for this account (set channels.matrix.threadBindings.spawnSubagentSessions=true to enable).",
error: formatThreadBindingSpawnDisabledError({
channel: policy.channel,
accountId: policy.accountId,
kind: "subagent",
}),
};
}

View File

@@ -63,7 +63,11 @@ type MatrixThreadBindingsConfig = {
enabled?: boolean;
idleHours?: number;
maxAgeHours?: number;
spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
};

View File

@@ -372,7 +372,7 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
},
threadBindings: {
enabled: true,
spawnSubagentSessions: true,
spawnSessions: true,
},
toolProfile: "coding",
},

View File

@@ -122,7 +122,7 @@ describe("matrix qa config", () => {
threadBindings: {
enabled: true,
idleHours: 1,
spawnSubagentSessions: true,
spawnSessions: true,
},
threadReplies: "always",
toolProfile: "coding",
@@ -182,7 +182,7 @@ describe("matrix qa config", () => {
threadBindings: {
enabled: true,
idleHours: 1,
spawnSubagentSessions: true,
spawnSessions: true,
},
threadReplies: "always",
});

View File

@@ -57,7 +57,11 @@ type MatrixQaThreadBindingsConfigOverrides = {
enabled?: boolean;
idleHours?: number;
maxAgeHours?: number;
spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
};
@@ -544,7 +548,7 @@ export function summarizeMatrixQaConfigSnapshot(snapshot: MatrixQaConfigSnapshot
`encryption=${formatMatrixQaBoolean(snapshot.encryption)}`,
`startupVerification=${snapshot.startupVerification ?? "<default>"}`,
`threadBindings.enabled=${snapshot.threadBindings.enabled ?? "<default>"}`,
`threadBindings.spawnSubagentSessions=${snapshot.threadBindings.spawnSubagentSessions ?? "<default>"}`,
`threadBindings.spawnSessions=${snapshot.threadBindings.spawnSessions ?? "<default>"}`,
`approvals.exec.enabled=${formatMatrixQaBoolean(snapshot.approvalForwarding.exec)}`,
`approvals.plugin.enabled=${formatMatrixQaBoolean(snapshot.approvalForwarding.plugin)}`,
].join(", ");

View File

@@ -161,12 +161,12 @@ export const telegramChannelConfigUiHints = {
label: "Telegram Thread Binding Max Age (hours)",
help: "Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
},
"threadBindings.spawnSubagentSessions": {
label: "Telegram Thread-Bound Subagent Spawn",
help: "Allow subagent spawns with thread=true to auto-bind Telegram current conversations when supported.",
"threadBindings.spawnSessions": {
label: "Telegram Thread-Bound Session Spawn",
help: "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported.",
},
"threadBindings.spawnAcpSessions": {
label: "Telegram Thread-Bound ACP Spawn",
help: "Allow ACP spawns with thread=true to auto-bind Telegram current conversations when supported.",
"threadBindings.defaultSpawnContext": {
label: "Telegram Thread Spawn Context",
help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".',
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -36,7 +36,7 @@ function createDefaultSpawnConfig(): OpenClawConfig {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
spawnSessions: true,
},
},
},
@@ -358,7 +358,7 @@ function enableMatrixAcpThreadBindings(): void {
matrix: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
spawnSessions: true,
},
},
},
@@ -418,7 +418,7 @@ function enableLineCurrentConversationBindings(): void {
line: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
spawnSessions: true,
},
},
},
@@ -1566,13 +1566,13 @@ describe("spawnAcpDirect", () => {
defaultAccount: "work",
threadBindings: {
enabled: true,
spawnAcpSessions: true,
spawnSessions: true,
},
accounts: {
work: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
spawnSessions: true,
},
},
},
@@ -1661,13 +1661,13 @@ describe("spawnAcpDirect", () => {
matrix: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
spawnSessions: true,
},
accounts: {
"bot-alpha": {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
spawnSessions: true,
},
},
},
@@ -2028,7 +2028,7 @@ describe("spawnAcpDirect", () => {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: false,
spawnSessions: false,
},
},
},
@@ -2048,7 +2048,7 @@ describe("spawnAcpDirect", () => {
},
);
expect(expectFailedSpawn(result, "error").error).toContain("spawnAcpSessions=true");
expect(expectFailedSpawn(result, "error").error).toContain("spawnSessions=true");
});
it("forbids ACP spawn from sandboxed requester sessions", async () => {

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { collectRuntimeChannelCapabilities } from "./runtime-capabilities.js";
describe("collectRuntimeChannelCapabilities", () => {
it("adds thread-bound spawn capabilities when the channel account allows unified spawns", () => {
const capabilities = collectRuntimeChannelCapabilities({
channel: "discord",
accountId: "default",
cfg: {
channels: {
discord: {
threadBindings: {
spawnSessions: true,
},
},
},
},
});
expect(capabilities).toContain("threadbound-subagent-spawn");
expect(capabilities).toContain("threadbound-acp-spawn");
});
it("omits thread-bound spawn capabilities when unified spawns are disabled", () => {
const capabilities = collectRuntimeChannelCapabilities({
channel: "discord",
accountId: "default",
cfg: {
channels: {
discord: {
threadBindings: {
spawnSessions: false,
},
},
},
},
});
expect(capabilities ?? []).not.toContain("threadbound-subagent-spawn");
expect(capabilities ?? []).not.toContain("threadbound-acp-spawn");
});
});

View File

@@ -1,8 +1,15 @@
import {
resolveThreadBindingSpawnPolicy,
supportsAutomaticThreadBindingSpawn,
} from "../channels/thread-bindings-policy.js";
import { resolveChannelCapabilities } from "../config/channel-capabilities.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { resolveChannelPromptCapabilities } from "./channel-tools.js";
const THREAD_BOUND_SUBAGENT_SPAWN_CAPABILITY = "threadbound-subagent-spawn";
const THREAD_BOUND_ACP_SPAWN_CAPABILITY = "threadbound-acp-spawn";
function mergeRuntimeCapabilities(
base?: readonly string[] | null,
additions: readonly string[] = [],
@@ -32,8 +39,27 @@ export function collectRuntimeChannelCapabilities(params: {
if (!params.channel) {
return undefined;
}
const threadSpawnCapabilities: string[] = [];
if (params.cfg && supportsAutomaticThreadBindingSpawn(params.channel)) {
for (const [kind, capability] of [
["subagent", THREAD_BOUND_SUBAGENT_SPAWN_CAPABILITY],
["acp", THREAD_BOUND_ACP_SPAWN_CAPABILITY],
] as const) {
const policy = resolveThreadBindingSpawnPolicy({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId ?? undefined,
kind,
});
if (policy.enabled && policy.spawnEnabled) {
threadSpawnCapabilities.push(capability);
}
}
}
return mergeRuntimeCapabilities(
resolveChannelCapabilities(params),
params.cfg ? resolveChannelPromptCapabilities(params) : [],
params.cfg
? [...resolveChannelPromptCapabilities(params), ...threadSpawnCapabilities]
: threadSpawnCapabilities,
);
}

View File

@@ -115,6 +115,42 @@ describe("sessions_spawn context modes", () => {
);
});
it("forks by default for thread-bound subagent sessions", async () => {
const store: SessionStore = {
main: {
sessionId: "parent-session-id",
sessionFile: "/tmp/parent-session.jsonl",
updatedAt: 1,
totalTokens: 1200,
},
};
usePersistentStoreMock(store);
forkSessionFromParentMock.mockImplementation(async () => ({
sessionId: "forked-session-id",
sessionFile: "/tmp/forked-session.jsonl",
}));
const prepareSubagentSpawn = vi.fn(async () => undefined);
resolveContextEngineMock.mockResolvedValue({ prepareSubagentSpawn });
const result = await spawnSubagentDirect(
{ task: "spin this into a thread", thread: true },
{
agentSessionKey: "main",
agentChannel: "discord",
agentAccountId: "default",
agentTo: "channel:123",
},
);
expect(result.status).toBe("error");
expect(forkSessionFromParentMock).toHaveBeenCalledWith({
parentEntry: store.main,
agentId: "main",
sessionsDir: path.dirname(storePath),
});
expect(prepareSubagentSpawn).not.toHaveBeenCalled();
});
it("initializes built-in context engines before resolving spawn preparation", async () => {
let initialized = false;
ensureContextEnginesInitializedMock.mockImplementation(() => {

View File

@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { isAcpRuntimeSpawnAvailable } from "../acp/runtime/availability.js";
import { resolveThreadBindingSpawnPolicy } from "../channels/thread-bindings-policy.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SubagentSpawnPreparation } from "../context-engine/types.js";
@@ -525,6 +526,29 @@ function resolveSpawnMode(params: {
return params.threadRequested ? "session" : "run";
}
function resolveSubagentContextMode(params: {
requestedContext?: SpawnSubagentContextMode;
threadRequested: boolean;
cfg: OpenClawConfig;
requester: {
channel?: string;
accountId?: string;
};
}): SpawnSubagentContextMode {
if (params.requestedContext === "fork" || params.requestedContext === "isolated") {
return params.requestedContext;
}
if (!params.threadRequested || !params.requester.channel) {
return "isolated";
}
return resolveThreadBindingSpawnPolicy({
cfg: params.cfg,
channel: params.requester.channel,
accountId: params.requester.accountId,
kind: "subagent",
}).defaultSpawnContext;
}
function summarizeError(err: unknown): string {
if (err instanceof Error) {
return err.message;
@@ -649,7 +673,6 @@ export async function spawnSubagentDirect(
const thinkingOverrideRaw = params.thinking;
const requestThreadBinding = params.thread === true;
const sandboxMode = params.sandbox === "require" ? "require" : "inherit";
const contextMode: SpawnSubagentContextMode = params.context === "fork" ? "fork" : "isolated";
const spawnMode = resolveSpawnMode({
requestedMode: params.mode,
threadRequested: requestThreadBinding,
@@ -682,6 +705,15 @@ export async function spawnSubagentDirect(
let modelApplied = false;
let threadBindingReady = false;
let hasBoundThreadDeliveryOrigin = false;
const contextMode = resolveSubagentContextMode({
requestedContext: params.context,
threadRequested: requestThreadBinding,
cfg,
requester: {
channel: ctx.agentChannel,
accountId: ctx.agentAccountId,
},
});
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const requesterSessionKey = ctx.agentSessionKey;
const requesterInternalKey = requesterSessionKey

View File

@@ -362,6 +362,10 @@ describe("buildAgentSystemPrompt", () => {
"Use ACP for Codex only when the user explicitly asks for ACP/acpx or wants to test the ACP path.",
],
acpEnabled: true,
runtimeInfo: {
channel: "discord",
capabilities: ["threadbound-acp-spawn"],
},
});
expect(prompt).toContain("Native Codex app-server plugin is available");
@@ -381,6 +385,24 @@ describe("buildAgentSystemPrompt", () => {
);
});
it("omits ACP thread-spawn guidance when the runtime capability is absent", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["sessions_spawn", "exec"],
acpEnabled: true,
runtimeInfo: {
channel: "discord",
capabilities: [],
},
});
expect(prompt).toContain(
'For requests like "do this in claude code/cursor/gemini/opencode" or similar ACP harnesses, treat it as ACP harness intent',
);
expect(prompt).not.toContain("default ACP harness requests to thread-bound");
expect(prompt).not.toContain('use `sessions_spawn` (`runtime: "acp"`, `thread: true`)');
});
it("omits ACP harness guidance when ACP is disabled", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",

View File

@@ -665,6 +665,7 @@ export function buildAgentSystemPrompt(params: {
runtimeCapabilities.map((cap) => normalizeLowercaseStringOrEmpty(cap)).filter(Boolean),
);
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
const threadBoundAcpSpawnEnabled = runtimeCapabilitiesLower.has("threadbound-acp-spawn");
const messageChannelOptions = listDeliverableMessageChannels().join("|");
const promptMode = params.promptMode ?? "full";
const isMinimal = promptMode === "minimal" || promptMode === "none";
@@ -754,9 +755,17 @@ export function buildAgentSystemPrompt(params: {
...(acpHarnessSpawnAllowed
? [
'For requests like "do this in claude code/cursor/gemini/opencode" or similar ACP harnesses, treat it as ACP harness intent and call `sessions_spawn` with `runtime: "acp"`.',
'On Discord, default ACP harness requests to thread-bound persistent sessions (`thread: true`, `mode: "session"`) unless the user asks otherwise.',
...(runtimeChannel === "discord" && threadBoundAcpSpawnEnabled
? [
'On Discord, default ACP harness requests to thread-bound persistent sessions (`thread: true`, `mode: "session"`) unless the user asks otherwise.',
]
: []),
"Set `agentId` explicitly unless `acp.defaultAgent` is configured, and do not route ACP harness requests through `subagents`/`agents_list` or local PTY exec flows.",
'For ACP harness thread spawns, do not call `message` with `action=thread-create`; use `sessions_spawn` (`runtime: "acp"`, `thread: true`) as the single thread creation path.',
...(threadBoundAcpSpawnEnabled
? [
'For ACP harness thread spawns, do not call `message` with `action=thread-create`; use `sessions_spawn` (`runtime: "acp"`, `thread: true`) as the single thread creation path.',
]
: []),
]
: []),
"Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).",

View File

@@ -33,10 +33,15 @@ export function describeSessionsSendTool(): string {
].join(" ");
}
export function describeSessionsSpawnTool(options?: { acpAvailable?: boolean }): string {
export function describeSessionsSpawnTool(options?: {
acpAvailable?: boolean;
threadAvailable?: boolean;
}): string {
const baseDescription = [
'Spawn a clean isolated session by default with `runtime="subagent"` or `runtime="acp"`.',
'`mode="run"` is one-shot and `mode="session"` is persistent or thread-bound.',
options?.threadAvailable
? '`mode="run"` is one-shot and `mode="session"` is persistent and thread-bound.'
: '`mode="run"` is one-shot background work.',
"Subagents inherit the parent workspace directory automatically.",
'For native subagents only, set `context="fork"` when the child needs the current transcript context; otherwise omit it or use `context="isolated"`.',
"Use this when the work should happen in a fresh child session instead of the current one.",

View File

@@ -186,6 +186,58 @@ describe("sessions_spawn tool", () => {
expect(schema.properties?.runtime?.enum).toEqual(["subagent", "acp"]);
});
it("hides thread-bound spawn fields when current channel disables spawnSessions", () => {
const tool = createSessionsSpawnTool({
agentChannel: "discord",
agentAccountId: "default",
config: {
channels: {
discord: {
threadBindings: {
spawnSessions: false,
},
},
},
},
});
const schema = tool.parameters as {
properties?: {
thread?: unknown;
mode?: { enum?: string[] };
};
};
expect(schema.properties?.thread).toBeUndefined();
expect(schema.properties?.mode?.enum).toEqual(["run"]);
expect(tool.description).not.toContain("thread-bound");
});
it("shows thread-bound spawn fields when current channel allows spawnSessions", () => {
const tool = createSessionsSpawnTool({
agentChannel: "discord",
agentAccountId: "default",
config: {
channels: {
discord: {
threadBindings: {
spawnSessions: true,
},
},
},
},
});
const schema = tool.parameters as {
properties?: {
thread?: unknown;
mode?: { enum?: string[] };
};
};
expect(schema.properties?.thread).toBeDefined();
expect(schema.properties?.mode?.enum).toEqual(["run", "session"]);
expect(tool.description).toContain("thread-bound");
});
it("uses subagent runtime by default", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",

View File

@@ -1,5 +1,9 @@
import { Type } from "typebox";
import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js";
import {
resolveThreadBindingSpawnPolicy,
supportsAutomaticThreadBindingSpawn,
} from "../../channels/thread-bindings-policy.js";
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { callGateway } from "../../gateway/call.js";
@@ -100,7 +104,45 @@ async function cleanupUntrackedAcpSession(sessionKey: string): Promise<void> {
}
}
function createSessionsSpawnToolSchema(params: { acpAvailable: boolean }) {
type SessionsSpawnThreadAvailability = {
subagent: boolean;
acp: boolean;
};
function hasAnyThreadAvailability(availability: SessionsSpawnThreadAvailability): boolean {
return availability.subagent || availability.acp;
}
function resolveSessionsSpawnThreadAvailability(opts?: {
config?: OpenClawConfig;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
}): SessionsSpawnThreadAvailability {
const channel = opts?.agentChannel;
const cfg = opts?.config;
if (!channel || !cfg || !supportsAutomaticThreadBindingSpawn(channel)) {
return { subagent: false, acp: false };
}
const resolve = (kind: "subagent" | "acp") => {
const policy = resolveThreadBindingSpawnPolicy({
cfg,
channel,
accountId: opts?.agentAccountId,
kind,
});
return policy.enabled && policy.spawnEnabled;
};
return {
subagent: resolve("subagent"),
acp: resolve("acp"),
};
}
function createSessionsSpawnToolSchema(params: {
acpAvailable: boolean;
threadAvailable: boolean;
}) {
const spawnModes = params.threadAvailable ? SUBAGENT_SPAWN_MODES : (["run"] as const);
const schema = {
task: Type.String(),
label: Type.Optional(Type.String()),
@@ -114,8 +156,17 @@ function createSessionsSpawnToolSchema(params: { acpAvailable: boolean }) {
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
// Back-compat: older callers used timeoutSeconds for this tool.
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
thread: Type.Optional(Type.Boolean()),
mode: optionalStringEnum(SUBAGENT_SPAWN_MODES),
...(params.threadAvailable
? {
thread: Type.Optional(
Type.Boolean({
description:
'Bind the spawned session to a new chat thread when the current channel/account supports thread-bound session spawns. `thread=true` defaults mode to "session".',
}),
),
}
: {}),
mode: optionalStringEnum(spawnModes),
cleanup: optionalStringEnum(["delete", "keep"] as const),
sandbox: optionalStringEnum(SESSIONS_SPAWN_SANDBOX_MODES),
context: optionalStringEnum(SUBAGENT_SPAWN_CONTEXT_MODES, {
@@ -194,14 +245,16 @@ export function createSessionsSpawnTool(
config: opts?.config,
sandboxed: opts?.sandboxed,
});
const threadAvailability = resolveSessionsSpawnThreadAvailability(opts);
const threadAvailable = hasAnyThreadAvailability(threadAvailability);
return {
label: "Sessions",
name: "sessions_spawn",
displaySummary: acpAvailable
? SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY
: SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY,
description: describeSessionsSpawnTool({ acpAvailable }),
parameters: createSessionsSpawnToolSchema({ acpAvailable }),
description: describeSessionsSpawnTool({ acpAvailable, threadAvailable }),
parameters: createSessionsSpawnToolSchema({ acpAvailable, threadAvailable }),
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const unsupportedParam = UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS.find((key) =>

View File

@@ -505,7 +505,7 @@ const baseCfg = {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
spawnSessions: true,
},
},
},
@@ -1178,7 +1178,7 @@ describe("/acp command", () => {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: false,
spawnSessions: false,
},
},
},
@@ -1290,7 +1290,7 @@ describe("/acp command", () => {
matrix: {
threadBindings: {
enabled: true,
spawnAcpSessions: false,
spawnSessions: false,
},
},
},
@@ -1318,7 +1318,7 @@ describe("/acp command", () => {
matrix: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
spawnSessions: true,
},
},
},
@@ -1346,7 +1346,7 @@ describe("/acp command", () => {
matrix: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
spawnSessions: true,
},
},
},
@@ -1417,14 +1417,14 @@ describe("/acp command", () => {
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});
it("rejects thread-bound ACP spawn when spawnAcpSessions is disabled", async () => {
it("rejects thread-bound ACP spawn when spawnSessions is disabled", async () => {
const cfg = {
...baseCfg,
channels: {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: false,
spawnSessions: false,
},
},
},
@@ -1432,7 +1432,7 @@ describe("/acp command", () => {
const result = await runDiscordAcpCommand("/acp spawn codex", cfg);
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
expect(result?.reply?.text).toContain("spawnSessions=true");
expect(hoisted.closeMock).toHaveBeenCalledTimes(2);
expect(hoisted.callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({ method: "sessions.delete" }),
@@ -1442,13 +1442,14 @@ describe("/acp command", () => {
);
});
it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is unset", async () => {
it("rejects Matrix thread-bound ACP spawn when spawnSessions is disabled", async () => {
const cfg = {
...baseCfg,
channels: {
matrix: {
threadBindings: {
enabled: true,
spawnSessions: false,
},
},
},
@@ -1456,7 +1457,7 @@ describe("/acp command", () => {
const result = await runMatrixAcpCommand("/acp spawn codex", cfg);
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
expect(result?.reply?.text).toContain("spawnSessions=true");
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});

View File

@@ -77,7 +77,7 @@ vi.mock("../../channels/thread-bindings-policy.js", () => ({
formatThreadBindingDisabledError: (params: { channel: string }) =>
`channels.${params.channel}.threadBindings.enabled=true required`,
formatThreadBindingSpawnDisabledError: (params: { channel: string }) =>
`channels.${params.channel}.threadBindings.spawnSubagentSessions=true`,
`channels.${params.channel}.threadBindings.spawnSessions=true`,
resolveThreadBindingIdleTimeoutMsForChannel: () => 24 * 60 * 60 * 1000,
resolveThreadBindingMaxAgeMsForChannel: () => undefined,
resolveThreadBindingPlacementForCurrentContext: (params: {
@@ -92,9 +92,10 @@ vi.mock("../../channels/thread-bindings-policy.js", () => ({
const settings = params.cfg.channels?.[params.channel]?.threadBindings;
return {
enabled: settings?.enabled !== false,
spawnEnabled: settings?.spawnSubagentSessions === true,
spawnEnabled: settings?.spawnSessions !== false,
channel: params.channel,
accountId: params.accountId,
defaultSpawnContext: "fork",
};
},
}));
@@ -367,7 +368,7 @@ describe("focus actions", () => {
[ROOM_CHANNEL]: {
threadBindings: {
enabled: true,
spawnSubagentSessions: true,
spawnSessions: true,
},
},
} as OpenClawConfig["channels"],
@@ -411,7 +412,7 @@ describe("focus actions", () => {
);
});
it("rejects room top-level thread creation when spawnSubagentSessions is disabled", async () => {
it("rejects room top-level thread creation when spawnSessions is disabled", async () => {
hoisted.resolveConversationBindingContextMock.mockReturnValue({
channel: ROOM_CHANNEL,
accountId: "default",
@@ -426,6 +427,7 @@ describe("focus actions", () => {
[ROOM_CHANNEL]: {
threadBindings: {
enabled: true,
spawnSessions: false,
},
},
} as OpenClawConfig["channels"],
@@ -434,7 +436,7 @@ describe("focus actions", () => {
);
expect(result.reply?.text).toContain(
`channels.${ROOM_CHANNEL}.threadBindings.spawnSubagentSessions=true`,
`channels.${ROOM_CHANNEL}.threadBindings.spawnSessions=true`,
);
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});

View File

@@ -4,6 +4,7 @@ import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/c
import {
requiresNativeThreadContextForThreadHere,
resolveThreadBindingPlacementForCurrentContext,
resolveThreadBindingSpawnPolicy,
supportsAutomaticThreadBindingSpawn,
} from "./thread-bindings-policy.js";
@@ -66,4 +67,74 @@ describe("thread binding spawn policy helpers", () => {
}),
).toBe("current");
});
it("enables unified thread-bound session spawns by default", () => {
const policy = resolveThreadBindingSpawnPolicy({
cfg: {},
channel: "discord",
kind: "subagent",
});
expect(policy).toMatchObject({
enabled: true,
spawnEnabled: true,
defaultSpawnContext: "fork",
});
});
it("uses spawnSessions for both subagent and ACP spawn policy", () => {
const cfg = {
channels: {
discord: {
threadBindings: {
spawnSessions: false,
},
},
},
};
expect(
resolveThreadBindingSpawnPolicy({
cfg,
channel: "discord",
kind: "subagent",
}).spawnEnabled,
).toBe(false);
expect(
resolveThreadBindingSpawnPolicy({
cfg,
channel: "discord",
kind: "acp",
}).spawnEnabled,
).toBe(false);
});
it("lets account config override channel spawnSessions and spawn context", () => {
const policy = resolveThreadBindingSpawnPolicy({
cfg: {
channels: {
discord: {
threadBindings: {
spawnSessions: false,
defaultSpawnContext: "fork",
},
accounts: {
work: {
threadBindings: {
spawnSessions: true,
defaultSpawnContext: "isolated",
},
},
},
},
},
},
channel: "discord",
accountId: "work",
kind: "subagent",
});
expect(policy.spawnEnabled).toBe(true);
expect(policy.defaultSpawnContext).toBe("isolated");
});
});

View File

@@ -20,8 +20,10 @@ type SessionThreadBindingsConfigShape = {
enabled?: unknown;
idleHours?: unknown;
maxAgeHours?: unknown;
spawnSessions?: unknown;
spawnSubagentSessions?: unknown;
spawnAcpSessions?: unknown;
defaultSpawnContext?: unknown;
};
type ChannelThreadBindingsContainerShape = {
@@ -36,8 +38,11 @@ export type ThreadBindingSpawnPolicy = {
accountId: string;
enabled: boolean;
spawnEnabled: boolean;
defaultSpawnContext: ThreadBindingSpawnContext;
};
export type ThreadBindingSpawnContext = "isolated" | "fork";
function normalizeChannelId(value: string | undefined | null): string {
return normalizeLowercaseStringOrEmpty(value);
}
@@ -153,6 +158,10 @@ function resolveSpawnFlagKey(
return kind === "subagent" ? "spawnSubagentSessions" : "spawnAcpSessions";
}
function normalizeSpawnContext(value: unknown): ThreadBindingSpawnContext | undefined {
return value === "isolated" || value === "fork" ? value : undefined;
}
export function resolveThreadBindingSpawnPolicy(params: {
cfg: OpenClawConfig;
channel: string;
@@ -173,13 +182,23 @@ export function resolveThreadBindingSpawnPolicy(params: {
true;
const spawnFlagKey = resolveSpawnFlagKey(params.kind);
const spawnEnabledRaw =
normalizeBoolean(account?.[spawnFlagKey]) ?? normalizeBoolean(root?.[spawnFlagKey]);
const spawnEnabled = spawnEnabledRaw ?? resolveDefaultTopLevelPlacement(channel) !== "child";
normalizeBoolean(account?.[spawnFlagKey]) ??
normalizeBoolean(account?.spawnSessions) ??
normalizeBoolean(root?.[spawnFlagKey]) ??
normalizeBoolean(root?.spawnSessions) ??
normalizeBoolean(params.cfg.session?.threadBindings?.spawnSessions);
const spawnEnabled = spawnEnabledRaw ?? true;
const defaultSpawnContext =
normalizeSpawnContext(account?.defaultSpawnContext) ??
normalizeSpawnContext(root?.defaultSpawnContext) ??
normalizeSpawnContext(params.cfg.session?.threadBindings?.defaultSpawnContext) ??
"fork";
return {
channel,
accountId,
enabled,
spawnEnabled,
defaultSpawnContext,
};
}
@@ -234,6 +253,5 @@ export function formatThreadBindingSpawnDisabledError(params: {
accountId: string;
kind: ThreadBindingSpawnKind;
}): string {
const spawnFlagKey = resolveSpawnFlagKey(params.kind);
return `Thread-bound ${params.kind} spawns are disabled for ${params.channel} (set channels.${params.channel}.threadBindings.${spawnFlagKey}=true to enable).`;
return `Thread-bound session spawns are disabled for ${params.channel} (set channels.${params.channel}.threadBindings.spawnSessions=true to enable).`;
}

View File

@@ -41,6 +41,56 @@ describe("legacy session maintenance migrate", () => {
});
});
describe("legacy thread binding spawn migrate", () => {
it("moves matching split spawn flags to unified spawnSessions", () => {
const res = migrateLegacyConfigForTest({
channels: {
discord: {
threadBindings: {
enabled: true,
spawnSubagentSessions: true,
spawnAcpSessions: true,
},
},
},
});
expect(res.config?.channels?.discord?.threadBindings).toEqual({
enabled: true,
spawnSessions: true,
});
expect(res.changes).toContain(
"Moved channels.discord.threadBindings.spawnSubagentSessions/spawnAcpSessions → channels.discord.threadBindings.spawnSessions (true).",
);
});
it("collapses conflicting split spawn flags conservatively", () => {
const res = migrateLegacyConfigForTest({
channels: {
discord: {
accounts: {
work: {
threadBindings: {
spawnSubagentSessions: true,
spawnAcpSessions: false,
},
},
},
},
},
});
expect(
res.config?.channels?.discord?.accounts?.work?.threadBindings as Record<string, unknown>,
).toEqual({
spawnSessions: false,
});
expect(res.changes).toContain(
"Collapsed conflicting channels.discord.accounts.work.threadBindings.spawnSubagentSessions/spawnAcpSessions → channels.discord.accounts.work.threadBindings.spawnSessions (false).",
);
});
});
describe("legacy migrate audio transcription", () => {
it("does not rewrite removed routing.transcribeAudio migrations", () => {
const res = migrateLegacyConfigForTest({

View File

@@ -14,6 +14,15 @@ function hasLegacyThreadBindingTtl(value: unknown): boolean {
return Boolean(threadBindings && hasOwnKey(threadBindings, "ttlHours"));
}
function hasLegacyThreadBindingSpawnSplit(value: unknown): boolean {
const threadBindings = getRecord(value);
return Boolean(
threadBindings &&
(hasOwnKey(threadBindings, "spawnSubagentSessions") ||
hasOwnKey(threadBindings, "spawnAcpSessions")),
);
}
function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean {
const accounts = getRecord(value);
if (!accounts) {
@@ -24,6 +33,16 @@ function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean {
);
}
function hasLegacyThreadBindingSpawnSplitInAccounts(value: unknown): boolean {
const accounts = getRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((entry) =>
hasLegacyThreadBindingSpawnSplit(getRecord(entry)?.threadBindings),
);
}
function migrateThreadBindingsTtlHoursForPath(params: {
owner: Record<string, unknown>;
pathPrefix: string;
@@ -53,6 +72,63 @@ function migrateThreadBindingsTtlHoursForPath(params: {
return true;
}
function resolveMigratedSpawnSessions(
threadBindings: Record<string, unknown>,
): boolean | undefined {
const subagent = threadBindings.spawnSubagentSessions;
const acp = threadBindings.spawnAcpSessions;
const subagentBool = typeof subagent === "boolean" ? subagent : undefined;
const acpBool = typeof acp === "boolean" ? acp : undefined;
if (subagentBool === undefined) {
return acpBool;
}
if (acpBool === undefined) {
return subagentBool;
}
return subagentBool && acpBool;
}
function migrateThreadBindingsSpawnSessionsForPath(params: {
owner: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): boolean {
const threadBindings = getRecord(params.owner.threadBindings);
if (!threadBindings || !hasLegacyThreadBindingSpawnSplit(threadBindings)) {
return false;
}
const hadSpawnSessions = threadBindings.spawnSessions !== undefined;
const resolved = resolveMigratedSpawnSessions(threadBindings);
const oldSubagent = threadBindings.spawnSubagentSessions;
const oldAcp = threadBindings.spawnAcpSessions;
delete threadBindings.spawnSubagentSessions;
delete threadBindings.spawnAcpSessions;
if (!hadSpawnSessions && resolved !== undefined) {
threadBindings.spawnSessions = resolved;
}
params.owner.threadBindings = threadBindings;
if (hadSpawnSessions) {
params.changes.push(
`Removed deprecated ${params.pathPrefix}.threadBindings.spawnSubagentSessions/spawnAcpSessions (${params.pathPrefix}.threadBindings.spawnSessions already set).`,
);
} else if (
typeof oldSubagent === "boolean" &&
typeof oldAcp === "boolean" &&
oldSubagent !== oldAcp
) {
params.changes.push(
`Collapsed conflicting ${params.pathPrefix}.threadBindings.spawnSubagentSessions/spawnAcpSessions → ${params.pathPrefix}.threadBindings.spawnSessions (${String(resolved)}).`,
);
} else {
params.changes.push(
`Moved ${params.pathPrefix}.threadBindings.spawnSubagentSessions/spawnAcpSessions → ${params.pathPrefix}.threadBindings.spawnSessions (${String(resolved)}).`,
);
}
return true;
}
function hasLegacyThreadBindingTtlInAnyChannel(value: unknown): boolean {
const channels = getRecord(value);
if (!channels) {
@@ -70,6 +146,23 @@ function hasLegacyThreadBindingTtlInAnyChannel(value: unknown): boolean {
});
}
function hasLegacyThreadBindingSpawnSplitInAnyChannel(value: unknown): boolean {
const channels = getRecord(value);
if (!channels) {
return false;
}
return Object.values(channels).some((entry) => {
const channel = getRecord(entry);
if (!channel) {
return false;
}
return (
hasLegacyThreadBindingSpawnSplit(channel.threadBindings) ||
hasLegacyThreadBindingSpawnSplitInAccounts(channel.accounts)
);
});
}
const THREAD_BINDING_RULES: LegacyConfigRule[] = [
{
path: ["session", "threadBindings"],
@@ -83,6 +176,18 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [
'channels.<id>.threadBindings.ttlHours was renamed to channels.<id>.threadBindings.idleHours. Run "openclaw doctor --fix".',
match: (value) => hasLegacyThreadBindingTtlInAnyChannel(value),
},
{
path: ["session", "threadBindings"],
message:
'session.threadBindings.spawnSubagentSessions/spawnAcpSessions were replaced by session.threadBindings.spawnSessions. Run "openclaw doctor --fix".',
match: (value) => hasLegacyThreadBindingSpawnSplit(value),
},
{
path: ["channels"],
message:
'channels.<id>.threadBindings.spawnSubagentSessions/spawnAcpSessions were replaced by channels.<id>.threadBindings.spawnSessions. Run "openclaw doctor --fix".',
match: (value) => hasLegacyThreadBindingSpawnSplitInAnyChannel(value),
},
];
export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
@@ -99,6 +204,11 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
pathPrefix: "session",
changes,
});
migrateThreadBindingsSpawnSessionsForPath({
owner: session,
pathPrefix: "session",
changes,
});
raw.session = session;
}
@@ -117,6 +227,11 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
pathPrefix: `channels.${channelId}`,
changes,
});
migrateThreadBindingsSpawnSessionsForPath({
owner: channel,
pathPrefix: `channels.${channelId}`,
changes,
});
const accounts = getRecord(channel.accounts);
if (accounts) {
@@ -130,6 +245,11 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
pathPrefix: `channels.${channelId}.accounts.${accountId}`,
changes,
});
migrateThreadBindingsSpawnSessionsForPath({
owner: account,
pathPrefix: `channels.${channelId}.accounts.${accountId}`,
changes,
});
accounts[accountId] = account;
}
channel.accounts = accounts;

View File

@@ -1451,6 +1451,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "number",
minimum: 0,
},
spawnSessions: {
type: "boolean",
},
defaultSpawnContext: {
type: "string",
enum: ["isolated", "fork"],
},
spawnSubagentSessions: {
type: "boolean",
},
@@ -2837,6 +2844,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "number",
minimum: 0,
},
spawnSessions: {
type: "boolean",
},
defaultSpawnContext: {
type: "string",
enum: ["isolated", "fork"],
},
spawnSubagentSessions: {
type: "boolean",
},
@@ -3565,13 +3579,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Discord Thread Binding Max Age (hours)",
help: "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
},
"threadBindings.spawnSubagentSessions": {
label: "Discord Thread-Bound Subagent Spawn",
help: "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.",
"threadBindings.spawnSessions": {
label: "Discord Thread-Bound Session Spawn",
help: "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel.",
},
"threadBindings.spawnAcpSessions": {
label: "Discord Thread-Bound ACP Spawn",
help: "Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.",
"threadBindings.defaultSpawnContext": {
label: "Discord Thread Spawn Context",
help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".',
},
"ui.components.accentColor": {
label: "Discord Component Accent Color",
@@ -7035,6 +7049,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
maxAgeHours: {
type: "number",
},
spawnSessions: {
type: "boolean",
},
defaultSpawnContext: {
type: "string",
enum: ["isolated", "fork"],
},
spawnSubagentSessions: {
type: "boolean",
},
@@ -7127,6 +7148,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
maxAgeHours: {
type: "number",
},
spawnSessions: {
type: "boolean",
},
defaultSpawnContext: {
type: "string",
enum: ["isolated", "fork"],
},
spawnSubagentSessions: {
type: "boolean",
},
@@ -7527,6 +7555,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "number",
minimum: 0,
},
spawnSessions: {
type: "boolean",
},
defaultSpawnContext: {
type: "string",
enum: ["isolated", "fork"],
},
spawnSubagentSessions: {
type: "boolean",
},
@@ -13628,6 +13663,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "number",
minimum: 0,
},
spawnSessions: {
type: "boolean",
},
defaultSpawnContext: {
type: "string",
enum: ["isolated", "fork"],
},
spawnSubagentSessions: {
type: "boolean",
},
@@ -14669,6 +14711,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "number",
minimum: 0,
},
spawnSessions: {
type: "boolean",
},
defaultSpawnContext: {
type: "string",
enum: ["isolated", "fork"],
},
spawnSubagentSessions: {
type: "boolean",
},
@@ -14935,13 +14984,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Telegram Thread Binding Max Age (hours)",
help: "Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
},
"threadBindings.spawnSubagentSessions": {
label: "Telegram Thread-Bound Subagent Spawn",
help: "Allow subagent spawns with thread=true to auto-bind Telegram current conversations when supported.",
"threadBindings.spawnSessions": {
label: "Telegram Thread-Bound Session Spawn",
help: "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported.",
},
"threadBindings.spawnAcpSessions": {
label: "Telegram Thread-Bound ACP Spawn",
help: "Allow ACP spawns with thread=true to auto-bind Telegram current conversations when supported.",
"threadBindings.defaultSpawnContext": {
label: "Telegram Thread Spawn Context",
help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".',
},
},
},

View File

@@ -20848,6 +20848,19 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.",
},
spawnSessions: {
type: "boolean",
title: "Thread-Bound Session Spawns",
description:
"Global default gate for creating thread-bound work sessions from sessions_spawn and ACP thread spawns. Default: true when thread bindings are enabled.",
},
defaultSpawnContext: {
type: "string",
enum: ["isolated", "fork"],
title: "Thread Spawn Context",
description:
'Default native subagent context for thread-bound spawns. Use "fork" to start from the requester transcript or "isolated" for a clean child. Default: "fork".',
},
},
additionalProperties: false,
title: "Session Thread Bindings",
@@ -27913,6 +27926,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.",
tags: ["performance", "storage"],
},
"session.threadBindings.spawnSessions": {
label: "Thread-Bound Session Spawns",
help: "Global default gate for creating thread-bound work sessions from sessions_spawn and ACP thread spawns. Default: true when thread bindings are enabled.",
tags: ["storage"],
},
"session.threadBindings.defaultSpawnContext": {
label: "Thread Spawn Context",
help: 'Default native subagent context for thread-bound spawns. Use "fork" to start from the requester transcript or "isolated" for a clean child. Default: "fork".',
tags: ["storage"],
},
"session.maintenance": {
label: "Session Maintenance",
help: "Automatic session-store maintenance controls for pruning age, entry caps, reset archive retention, and disk budget cleanup. Start in warn mode to observe impact, then enforce once thresholds are tuned.",

View File

@@ -176,6 +176,8 @@ const TARGET_KEYS = [
"session.threadBindings.enabled",
"session.threadBindings.idleHours",
"session.threadBindings.maxAgeHours",
"session.threadBindings.spawnSessions",
"session.threadBindings.defaultSpawnContext",
"session.maintenance",
"session.maintenance.mode",
"session.maintenance.pruneAfter",

View File

@@ -1461,6 +1461,10 @@ export const FIELD_HELP: Record<string, string> = {
"Default inactivity window in hours for thread-bound sessions across providers/channels (0 disables idle auto-unfocus). Default: 24.",
"session.threadBindings.maxAgeHours":
"Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.",
"session.threadBindings.spawnSessions":
"Global default gate for creating thread-bound work sessions from sessions_spawn and ACP thread spawns. Default: true when thread bindings are enabled.",
"session.threadBindings.defaultSpawnContext":
'Default native subagent context for thread-bound spawns. Use "fork" to start from the requester transcript or "isolated" for a clean child. Default: "fork".',
"session.maintenance":
"Automatic session-store maintenance controls for pruning age, entry caps, reset archive retention, and disk budget cleanup. Start in warn mode to observe impact, then enforce once thresholds are tuned.",
"session.maintenance.mode":

View File

@@ -723,6 +723,8 @@ export const FIELD_LABELS: Record<string, string> = {
"session.threadBindings.enabled": "Thread Binding Enabled",
"session.threadBindings.idleHours": "Thread Binding Idle Timeout (hours)",
"session.threadBindings.maxAgeHours": "Thread Binding Max Age (hours)",
"session.threadBindings.spawnSessions": "Thread-Bound Session Spawns",
"session.threadBindings.defaultSpawnContext": "Thread Spawn Context",
"session.maintenance": "Session Maintenance",
"session.maintenance.mode": "Session Maintenance Mode",
"session.maintenance.pruneAfter": "Session Prune After",

View File

@@ -153,6 +153,17 @@ export type SessionThreadBindingsConfig = {
* Session auto-unfocuses once this age is reached even if active. Set to 0 to disable. Default: 0.
*/
maxAgeHours?: number;
/**
* Allow channel integrations to create thread-bound work sessions from
* sessions_spawn or native ACP spawn flows. Channel/account keys can override.
* Default: true when thread bindings are enabled.
*/
spawnSessions?: boolean;
/**
* Default context mode for native subagents spawned into a bound thread.
* Default: "fork" so the child starts from the requester transcript.
*/
defaultSpawnContext?: "isolated" | "fork";
};
export type SessionConfig = {

View File

@@ -57,7 +57,11 @@ export type ExtensionChannelConfig = {
execApprovals?: Record<string, unknown>;
threadBindings?: {
enabled?: boolean;
spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
};
spawnSubagentSessions?: boolean;

View File

@@ -197,13 +197,21 @@ export type DiscordThreadBindingsConfig = {
*/
maxAgeHours?: number;
/**
* Allow `sessions_spawn({ thread: true })` to auto-create + bind Discord
* threads for subagent sessions. Default: false (opt-in).
* Allow session spawns to auto-create + bind Discord threads.
* Applies to native subagent and ACP thread spawns. Default: true.
*/
spawnSessions?: boolean;
/**
* Default context mode for native subagents spawned into a bound Discord thread.
* Default: "fork".
*/
defaultSpawnContext?: "isolated" | "fork";
/**
* @deprecated Use spawnSessions instead.
*/
spawnSubagentSessions?: boolean;
/**
* Allow `/acp spawn` to auto-create + bind Discord threads for ACP
* sessions. Default: false (opt-in).
* @deprecated Use spawnSessions instead.
*/
spawnAcpSessions?: boolean;
};

View File

@@ -32,13 +32,11 @@ export type TelegramActionConfig = {
export type TelegramThreadBindingsConfig = SessionThreadBindingsConfig & {
/**
* Allow `sessions_spawn({ thread: true })` to auto-create + bind Telegram
* topics for subagent sessions. Default: false (opt-in).
* @deprecated Use spawnSessions instead.
*/
spawnSubagentSessions?: boolean;
/**
* Allow `/acp spawn` to auto-create + bind Telegram topics for ACP
* sessions. Default: false (opt-in).
* @deprecated Use spawnSessions instead.
*/
spawnAcpSessions?: boolean;
};

View File

@@ -313,6 +313,8 @@ export const TelegramAccountSchemaBase = z
enabled: z.boolean().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
})
@@ -612,6 +614,8 @@ export const DiscordAccountSchema = z
enabled: z.boolean().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
})

View File

@@ -67,6 +67,8 @@ export const SessionSchema = z
enabled: z.boolean().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
})
.strict()
.optional(),

View File

@@ -45,6 +45,7 @@ export {
} from "../channels/thread-bindings-messages.js";
export {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingEffectiveExpiresAt,
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingIdleTimeoutMsForChannel,