diff --git a/README.md b/README.md index c1e7a8a..1407308 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,8 @@ Then invite the app to the channel and pair in that channel/thread with `relay p | pause delivery | `/pause` | `relay pause` | `/relay pause` | | resume delivery | `/resume` | `relay resume` | `/relay resume` | | disconnect binding | `/disconnect` | `relay disconnect` | `/relay disconnect` | +| create delegation task (opt-in shared rooms) | `/delegate ` | `relay delegate ` | `/relay delegate ` | +| control delegation task | `/task@ [task-id]` (or `/task [task-id]` in private/other clients) | `relay task [task-id]` | `/relay task [task-id]` | `quiet`, `normal`, `verbose`, and `completion-only` are valid progress modes. In quiet mode PiRelay keeps terminal notifications concise and offers `/full`/download actions for the full answer. In normal, verbose, and completion-only modes it sends the full final answer, splitting by paragraphs within platform limits and falling back to a Markdown document when an adapter supports files and the output is too large for a reasonable chat burst. @@ -274,6 +276,8 @@ Remote `/disconnect` is scoped to the requesting chat/conversation only: it revo Remote `send-file` is requester-scoped: an authorized Telegram/Discord/Slack user may request a workspace-relative, validated path and PiRelay uploads it only back to that same conversation/thread. Targeted fan-out remains local-only via `/relay send-file [caption]`; remote forms must not include messenger targets such as `all` or `slack`. +Agent delegation is disabled by default and only applies in explicitly enabled shared rooms. Delegation task cards are visible room messages; bot-authored ordinary output remains inert, peer-bot trust is configured separately from human allow-lists, and claimed work is injected as a bounded delegated-task prompt with completion/failure reported back to the room. + ## Prompt routing behavior ### When Pi is idle diff --git a/docs/adapters.md b/docs/adapters.md index 414247e..4236b9f 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -77,7 +77,9 @@ Run one PiRelay broker per machine. If the same bot/account is configured on mul Discord has an opt-in live DM-first runtime backed by the Discord adapter and live client operations. Slack remains a DM-first foundation with mockable platform operations. Adapters normalize direct-message text, action callbacks, files/images, identity metadata, and platform limits into shared relay contracts. Discord guild messages and Slack channel events remain rejected by default unless explicitly enabled. -Shared-room parity is tracked in `docs/shared-room-parity.md`. Telegram supports addressed group commands and optional Bot-to-Bot Communication Mode when both BotFather bots enable it. Discord has the closest runtime parity for shared guild channels. Slack can parse app mentions, but ordinary channel text, channel-command fallback, and media routing are intentionally reported as unsupported until Slack gets Discord-like pre-routing. +Shared-room parity is tracked in `docs/shared-room-parity.md`. Telegram supports addressed group commands and optional Bot-to-Bot Communication Mode when both BotFather bots enable it. Discord supports gated guild-channel shared rooms with text fallbacks, mentions, buttons, and delegation task cards. Slack supports explicitly enabled and paired channel/thread control with app mentions, ordinary text, `relay ` fallbacks, and delegation text cards; media parity remains narrower than private-chat flows. + +Agent delegation is handled above adapter-specific ingress as a structured task-card/control surface. Adapters may expose buttons or text fallbacks, but they must preserve sender bot metadata, reject untrusted peers before prompt injection, ignore local-bot/self-authored events, and keep arbitrary bot-authored output inert. ## Future adapters diff --git a/docs/config.md b/docs/config.md index 0da1bec..f23ac1b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -98,7 +98,31 @@ For no-federation shared rooms, use one dedicated bot/app identity per machine i - Telegram: invite each machine bot to the group/supergroup. Enable BotFather Bot-to-Bot Communication Mode for both bots only when testing bot-authored workflows; `/command@bot` addressed commands remain the reliable privacy-mode fallback. - Discord: enable guild-channel shared rooms only with dedicated applications, allowed guild ids, Message Content Intent, and channel permissions. Prefer `relay ` and mentions over platform slash-command assumptions. -- Slack: channel events and app mentions can be configured, but Slack shared-room ordinary text/channel command/media pre-routing is diagnostic/deferred until explicit runtime support exists. Keep channel control disabled unless you are testing that gap deliberately. +- Slack: channel events, app mentions, ordinary channel text, and `relay ` fallbacks are supported only after `allowChannelMessages`, shared-room enablement, app invitation, and explicit channel pairing are configured. + +Agent delegation is disabled by default. Enable it per messenger instance with a `delegation` block, for example: + +```json +{ + "relay": { "machineId": "laptop", "capabilities": ["linux-tests"] }, + "messengers": { + "discord": { + "default": { + "sharedRoom": { "enabled": true }, + "delegation": { + "enabled": true, + "autonomy": "propose-only", + "trustedPeers": [ + { "peerId": "1234567890", "allowCreate": true, "targetMachineIds": ["laptop"] } + ] + } + } + } + } +} +``` + +Supported autonomy levels are `off`, `propose-only`, `auto-claim-targeted`, and `auto-claim-safe-capability`. Peer-bot trust is separate from human `allowUserIds`; do not put tokens, hidden prompts, transcripts, or raw tool inputs in delegation task goals. See `docs/shared-room-parity.md` for the current parity matrix. diff --git a/docs/shared-room-parity.md b/docs/shared-room-parity.md index 7a74f51..8a29c6d 100644 --- a/docs/shared-room-parity.md +++ b/docs/shared-room-parity.md @@ -2,6 +2,8 @@ PiRelay shared rooms use one dedicated machine bot/app per Pi machine in the same messenger room. Defaults remain conservative: private chats are always the safest pairing surface, and shared-room control must be explicitly enabled per platform. +Agent delegation is an additional opt-in layer for shared rooms. When enabled, authorized humans or trusted peer bots can create visible task cards with `/delegate ` and control them with `/task [task-id]`. Bot-authored ordinary output remains inert; only validated delegation commands/actions are machine-actionable. + ## Telegram - Private chats: supported. @@ -21,13 +23,13 @@ PiRelay shared rooms use one dedicated machine bot/app per Pi machine in the sam ## Slack - Private chats: supported. -- Shared rooms: partially declared at adapter level, but Discord-like channel pre-routing is not yet runtime-parity. +- Shared rooms: supported for explicitly enabled channel/thread control when `allowChannelMessages`, shared-room enablement, app membership, and user authorization are configured. - App mentions: detected and classified as local, remote, or ambiguous by user ID. -- Deferred/diagnostic-only shared-room surfaces: ordinary channel text, channel command fallback, and media attachments in shared rooms. Diagnostics should say this explicitly instead of implying full Telegram/Discord parity. -- Safe default: keep channel control disabled unless an implementation adds explicit pre-routing, authorization, active selection, non-target silence, and safe response handling. +- Ordinary channel text and `relay ` fallback: supported after channel pairing and active-selection checks; non-target machine bots stay silent. +- Safe default: keep channel control disabled unless the app is installed in the intended room with explicit user allow-lists and a tested pairing path. ## Capability summary - Telegram: mentions/replies/platform addressed commands/media supported; ordinary text depends on group privacy and permissions. - Discord: ordinary text/mentions/platform text prefix/media supported when guild channel mode is enabled and authorized. -- Slack: mentions can be parsed; ordinary text/platform commands/media shared-room routing is intentionally marked unsupported until Slack runtime pre-routing is implemented. +- Slack: app mentions, ordinary text, and platform command fallbacks are supported for explicitly enabled and paired channel/thread control; media parity remains narrower than private-chat flows. diff --git a/docs/slack-live-integration.md b/docs/slack-live-integration.md index 17e635b..eafb695 100644 --- a/docs/slack-live-integration.md +++ b/docs/slack-live-integration.md @@ -44,7 +44,7 @@ Set these variables only in a secure local shell or CI secret store: export PI_RELAY_SLACK_LIVE_ENABLED=true export PI_RELAY_SLACK_LIVE_WORKSPACE_ID=T123... export PI_RELAY_SLACK_LIVE_CHANNEL_ID=C123... # or G123... for private channels -export PI_RELAY_SLACK_LIVE_AUTHORIZED_USER_ID=U123... +export PI_RELAY_SLACK_LIVE_AUTHORIZED_USER_ID=U123... # must be your Slack user ID (the sender of manual commands) export PI_RELAY_SLACK_LIVE_DRIVER_TOKEN=xoxp-or-test-driver-token export PI_RELAY_SLACK_LIVE_EVENT_MODE=socket # default; use webhook only with external delivery export PI_RELAY_SLACK_LIVE_REAL_AGENT=false # set true for real LLM-backed Pi agent runs @@ -66,20 +66,38 @@ Optional: ```bash export PI_RELAY_SLACK_LIVE_TIMEOUT_MS=120000 # defaults to 300000 when PI_RELAY_SLACK_LIVE_REAL_AGENT=true -export PI_RELAY_SLACK_LIVE_BOT_A_INSTANCE_ID=slack-live-a -export PI_RELAY_SLACK_LIVE_BOT_B_INSTANCE_ID=slack-live-b -export PI_RELAY_SLACK_LIVE_BOT_A_DISPLAY_NAME='PiRelay Slack A' -export PI_RELAY_SLACK_LIVE_BOT_B_DISPLAY_NAME='PiRelay Slack B' +export PI_RELAY_SLACK_LIVE_BOT_A_INSTANCE_ID=pirelay__mini_ # machine id used in relay delegate machine arguments +export PI_RELAY_SLACK_LIVE_BOT_B_INSTANCE_ID=pirelay__work_ # machine id used in relay delegate machine arguments +export PI_RELAY_SLACK_LIVE_BOT_A_DISPLAY_NAME='pirelay__mini_' +export PI_RELAY_SLACK_LIVE_BOT_B_DISPLAY_NAME='pirelay__work_' +export PI_RELAY_SLACK_LIVE_DELEGATION_ENABLED=false # set true to enable delegation-only live coverage +export PI_RELAY_SLACK_LIVE_DELEGATION_AUTONOMY=auto-claim-targeted +export PI_RELAY_SLACK_LIVE_DELEGATION_REQUIRE_HUMAN_APPROVAL=false +export PI_RELAY_SLACK_LIVE_DELEGATION_MANUAL=false # optional interactive/manual message-post mode for local runs + +# If your Slack bots now have different names, update *_INSTANCE_ID (and *_DISPLAY_NAME for log readability) +# so the printed commands line up with the names you want to target. ``` -The harness writes per-instance config files under a temporary directory, points each Pi process at a distinct `PI_RELAY_CONFIG`/`PI_RELAY_STATE_DIR`, and passes the relevant Slack token/signing-secret/app-level token values via environment variables. Temporary state is deleted during teardown so repeated runs do not reuse stale local bindings. The live harness enables a test-only pre-seeded binding path for its disposable channel so targeted prompts exercise real runtime prompt routing and completion notifications without committing pairing codes. Set `PI_RELAY_SLACK_LIVE_REAL_AGENT=true` when `PI_RELAY_SLACK_LIVE_BOT_A_PI_COMMAND` and `PI_RELAY_SLACK_LIVE_BOT_B_PI_COMMAND` launch real LLM-backed Pi agents; this switches the prompt wording to an explicit marker-only instruction and increases the default timeout to five minutes while still asserting only that the marker appears. Production Socket Mode uses the same token shape: a bot token (`xoxb-...`) plus an app-level token (`xapp-...`) with `connections:write`. Prefer namespaced PiRelay config (`tokenEnv`, `signingSecretEnv`, and `appTokenEnv`) for non-test runs; `PI_RELAY_SLACK_BOT_USER_ID`/`slack.botUserId` is only a non-secret fallback when startup `auth.test` discovery is unavailable. The live harness also enables the bounded history-polling fallback for diagnostics, but production prompt routing should use Socket Mode events. +The harness writes per-instance config files under a temporary directory, points each Pi process at a distinct `PI_RELAY_CONFIG`/`PI_RELAY_STATE_DIR`, and passes the relevant Slack token/signing-secret/app-level token values via environment variables. Temporary state is deleted during teardown so repeated runs do not reuse stale local bindings. The live harness enables a test-only pre-seeded binding path for its disposable channel so targeted prompts exercise real runtime prompt routing and completion notifications without committing pairing codes. Set `PI_RELAY_SLACK_LIVE_REAL_AGENT=true` when `PI_RELAY_SLACK_LIVE_BOT_A_PI_COMMAND` and `PI_RELAY_SLACK_LIVE_BOT_B_PI_COMMAND` launch real LLM-backed Pi agents; this switches the prompt wording to an explicit marker-only instruction and increases the default timeout to five minutes while still asserting only that the marker appears. In that mode, per-instance broker namespaces are also enabled so multiple bot apps can run on the same machine without session collisions. `PI_RELAY_SLACK_LIVE_REAL_AGENT=false` uses lightweight stub-style sessions intended for command-routing checks, so `/status` output may show offline even while delegation messaging still works for live validation. Production Socket Mode uses the same token shape: a bot token (`xoxb-...`) plus an app-level token (`xapp-...`) with `connections:write`. Prefer namespaced PiRelay config (`tokenEnv`, `signingSecretEnv`, and `appTokenEnv`) for non-test runs; `PI_RELAY_SLACK_BOT_USER_ID`/`slack.botUserId` is only a non-secret fallback when startup `auth.test` discovery is unavailable. The live harness also enables the bounded history-polling fallback for diagnostics, but production prompt routing should use Socket Mode events. ## Running locally ```bash npm run test -- tests/slack-live-integration.test.ts +./run-slack-live-test.sh ``` +`run-slack-live-test.sh` is parameterizable. Use the `--delegation` option (and optional `--test`) for the new live delegation suite: + +```bash +./run-slack-live-test.sh --delegation +./run-slack-live-test.sh --test tests/slack-live-delegation.test.ts --delegation +./run-slack-live-test.sh --delegation --manual-delegation --test tests/slack-live-delegation.test.ts +``` + +In manual mode, the test prints the exact machine ID and display name it expects you to target, plus the command examples, so you can copy-paste directly into Slack. Delegation task cards use Slack buttons for claim, decline, cancel, and status actions when callbacks are available; the card text also includes `relay task ...` fallback commands for manual copy-paste or environments where button callbacks are unavailable. In real-agent mode, the delegation test waits for a completed task card with a bounded `Result` summary rather than stopping at the running handoff card. + When `PI_RELAY_SLACK_LIVE_ENABLED` or required credentials are absent, the test is skipped and prints which configuration is missing. The normal `npm test` run is safe without live Slack secrets. ## CI guidance diff --git a/docs/testing.md b/docs/testing.md index 921b84b..e04ef7c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -136,6 +136,9 @@ These adapter foundations are DM-first and use channel-specific credentials/conf 25. Pair one Pi session to Telegram and Slack or Discord, send remote `/disconnect` from only one conversation, then complete a Pi turn from the still-paired messenger; verify the disconnected conversation receives no completion, progress, image/file, or button output while `/sessions` still returns safe no-paired-session guidance there. 26. Repeat the previous step while a turn is already running; verify a disconnect racing with completion suppresses output to the revoked conversation. 27. From local Pi, run `/relay disconnect` and verify all Telegram, Discord, and Slack bindings for the current session are revoked and no messenger can continue controlling that session until re-paired. +28. In a shared room with delegation enabled, send `/delegate run a harmless status check`; verify a visible task card appears, non-target machine bots stay silent, `/task claim ` injects a bounded delegated-task prompt only into the claimant session, and completion is reported back to the room. +29. Repeat with an untrusted bot-authored `/delegate` message and verify no task is created, no prompt is injected, and no media/download/callback side effects occur. +30. Repeat with a trusted peer bot targeting a different machine and verify the local broker stays silent except for local observation state. ## 7. Optional Telegram two-bot shared-room smoke checklist diff --git a/extensions/relay/adapters/discord/adapter.ts b/extensions/relay/adapters/discord/adapter.ts index 2d98cfd..d8d33d4 100644 --- a/extensions/relay/adapters/discord/adapter.ts +++ b/extensions/relay/adapters/discord/adapter.ts @@ -15,6 +15,7 @@ import type { import { assertCanSendOutboundFile, channelTextChunks, decodeOutboundFileData } from "../../core/channel-adapter.js"; import type { DiscordRelayConfig } from "../../core/types.js"; import type { SharedRoomAddressing } from "../../core/shared-room.js"; +import { parseDelegationInvocation } from "../../commands/delegation.js"; export interface DiscordApiOperations { connect?(handler: (event: DiscordGatewayEvent) => Promise): Promise; @@ -216,8 +217,9 @@ export function discordGatewayEventToChannelEvent(event: DiscordGatewayEvent, co : discordMessageToChannelEvent(event.payload as DiscordMessagePayload, config); } -export function discordMessageToChannelEvent(message: DiscordMessagePayload, config: Pick): ChannelInboundMessage | undefined { - if (message.author.bot || message.webhook_id) return undefined; +export function discordMessageToChannelEvent(message: DiscordMessagePayload, config: Pick): ChannelInboundMessage | undefined { + if (message.webhook_id) return undefined; + if (message.author.bot && (!config.delegation?.enabled || !parseDelegationInvocation(message.content ?? "", { prefixes: ["relay"] }))) return undefined; const conversation = discordConversation(message.channel_id, message.guild_id); const sender = discordIdentity(message.author, message.guild_id); return { @@ -326,13 +328,13 @@ function discordConversation(channelId: string, guildId?: string): ChannelConver }; } -function discordIdentity(user: { id: string; username?: string; global_name?: string; discriminator?: string }, guildId?: string): ChannelIdentity { +function discordIdentity(user: { id: string; username?: string; global_name?: string; discriminator?: string; bot?: boolean }, guildId?: string): ChannelIdentity { return { channel: DISCORD_CHANNEL, userId: user.id, username: user.username, displayName: user.global_name ?? user.username, - metadata: { discriminator: user.discriminator, guildId }, + metadata: { discriminator: user.discriminator, guildId, isBot: user.bot === true }, }; } diff --git a/extensions/relay/adapters/discord/runtime.ts b/extensions/relay/adapters/discord/runtime.ts index af044a8..d7a9f5d 100644 --- a/extensions/relay/adapters/discord/runtime.ts +++ b/extensions/relay/adapters/discord/runtime.ts @@ -1,10 +1,11 @@ -import type { ChannelBinding, ChannelInboundAction, ChannelInboundEvent, ChannelInboundMessage, ChannelOutboundFile, ChannelRouteAddress } from "../../core/channel-adapter.js"; +import type { ChannelBinding, ChannelButtonLayout, ChannelInboundAction, ChannelInboundEvent, ChannelInboundMessage, ChannelOutboundFile, ChannelRouteAddress } from "../../core/channel-adapter.js"; import { completeDiscordPairing } from "../channel-pairing.js"; import { DiscordChannelAdapter, discordMentionsSharedRoomAddressing, discordPairingCommand, discordRelayPairingCommand, isDiscordIdentityAllowed, type DiscordApiOperations } from "./adapter.js"; import { createDiscordLiveOperations } from "./live-client.js"; import { TunnelStateStore } from "../../state/tunnel-store.js"; import type { ChannelPersistedBindingRecord, LatestTurnImage, PairingApprovalDecision, ProgressMode, SessionRoute, TelegramTunnelConfig } from "../../core/types.js"; import { commandAllowsWhilePaused, normalizeAliasArg, parseRemoteCommandInvocation, buildHelpText } from "../../commands/remote.js"; +import { delegationTaskActionButtons, parseDelegationInvocation, renderDelegationTaskCard } from "../../commands/delegation.js"; import { formatFullOutput, formatLatestImageEmptyMessage, formatRelayRecentActivity, formatRelayStatusForRoute, formatSessionSelectorError, formatSummaryOutput } from "../../formatting/presenters.js"; import { formatSessionList, resolveSessionSelector, resolveSessionTargetArgs, type SessionListEntry } from "../../core/session-selection.js"; import { displayProgressMode, normalizeProgressMode, progressModeFor } from "../../notifications/progress.js"; @@ -17,6 +18,8 @@ import { redactSecrets } from "../../config/setup.js"; import { buildImagePromptContent, modelSupportsImages, summarizeTextDeterministically } from "../../core/utils.js"; import { deliverWorkspaceFileToRequester, formatRequesterFileDeliveryResult, parseRemoteSendFileArgs, type RelayFileDeliveryRequester } from "../../core/requester-file-delivery.js"; import { classifySharedRoomEvent, normalizeMachineSelector, parseSharedRoomSessionsArgs, parseSharedRoomToArgs, parseSharedRoomUseArgs, resolveSharedRoomMachineTarget, sharedRoomAddressingFromEvent, sharedRoomMachineIdentity, type SharedRoomAddressing, type SharedRoomMachineIdentity } from "../../core/shared-room.js"; +import { buildDelegatedTaskPrompt, delegationCommandFromAction, delegationIngressEventKey, delegationRoomFromMessage, evaluateDelegationIngress, isPeerBotIdentity } from "../../core/agent-delegation-runtime.js"; +import { transitionDelegationTask, type DelegationTaskRecord } from "../../core/agent-delegation.js"; const DISCORD_CHANNEL = "discord" as const; const IMAGE_PROMPT_FALLBACK = "Please inspect the attached image."; @@ -54,6 +57,7 @@ export class DiscordRuntime { private readonly recentBindingBySessionKey = new Map(); private readonly typingStates = new Map }>(); private readonly invalidPairingAttempts = new Map(); + private readonly activeDelegationTaskBySessionKey = new Map(); private started = false; private startPromise?: Promise; private lastError?: string; @@ -79,7 +83,8 @@ export class DiscordRuntime { async start(): Promise { if (!this.adapter || this.started) return; if (this.startPromise) return this.startPromise; - this.startPromise = this.adapter.startPolling(async (event) => this.handleEvent(event)) + this.startPromise = this.store.markInFlightDelegationTasksStaleAfterRestart() + .then(() => this.adapter!.startPolling(async (event) => this.handleEvent(event))) .then(() => { this.started = true; this.lastError = undefined; @@ -145,6 +150,7 @@ export class DiscordRuntime { async notifyTurnCompleted(route: SessionRoute, status: "completed" | "failed" | "aborted"): Promise { this.stopTypingActivity(route.sessionKey); + if (await this.finishActiveDelegationTask(route, status)) return; if (!this.adapter) return; const binding = await this.activeBindingForRoute(route, { includePaused: true }); if (!binding) return; @@ -199,6 +205,7 @@ export class DiscordRuntime { if (!this.adapter || event.channel !== DISCORD_CHANNEL) return; try { if (event.kind === "action") { + if (await this.handleDelegationAction(event)) return; await this.handleAction(event); return; } @@ -224,16 +231,28 @@ export class DiscordRuntime { } const command = parseDiscordCommand(text) ?? parseDiscordCommand(stripLeadingDiscordMentions(text)); + const delegationCommand = parseDelegationInvocation(text, { prefixes: ["relay"] }) ?? parseDelegationInvocation(stripLeadingDiscordMentions(text), { prefixes: ["relay"] }); + if (delegationCommand && this.isSharedRoomMessage(message)) { + if (await this.handleDelegationMessage(message, delegationCommand)) return; + return; + } + if (isPeerBotIdentity(message.sender)) return; if (this.isSharedRoomMessage(message) && !isDiscordIdentityAllowed(message.sender, this.config.discord)) { if (this.shouldRejectUnauthorizedSharedRoomEvent(message, command)) { await this.sendText(message, "This Discord identity is not authorized to control this PiRelay machine bot."); } return; } - const sharedRoomDecision = await this.applySharedRoomPreRouting(message, command); + const sharedRoomDecision = this.isSharedRoomMessage(message) ? await this.applySharedRoomPreRouting(message, command) : { kind: "continue" as const }; if (sharedRoomDecision.kind === "silent") return; - const routedMessage = sharedRoomDecision.message ?? message; - const routedCommand = sharedRoomDecision.command ?? command; + const routedMessage = sharedRoomDecision.kind === "continue" ? sharedRoomDecision.message ?? message : message; + const routedCommand = sharedRoomDecision.kind === "continue" ? sharedRoomDecision.command ?? command : command; + if (this.isSharedRoomMessage(message) && !isDiscordIdentityAllowed(routedMessage.sender, this.config.discord)) { + if (this.shouldRejectUnauthorizedSharedRoomEvent(routedMessage, routedCommand)) { + await this.sendText(routedMessage, "This Discord identity is not authorized to control this PiRelay machine bot."); + } + return; + } const preferredSessionKey = routedCommand?.name === "to" ? await this.targetSessionKeyForToCommand(routedMessage, routedCommand.args) : await this.sharedRoomPreferredSessionKey(routedMessage); const binding = await this.findDiscordBinding(routedMessage, { preferredSessionKey }); if (!binding || !isDiscordIdentityAllowed(routedMessage.sender, this.config.discord)) { @@ -367,6 +386,186 @@ export class DiscordRuntime { return active?.machineId && active.machineId !== this.sharedRoomMachineIdentity().machineId ? undefined : active?.sessionKey; } + private isDiscordDelegationSourceScopedToLocal(message: ChannelInboundMessage): boolean { + return this.sharedRoomAddressing(message)?.kind === "local"; + } + + private async handleDelegationAction(action: ChannelInboundAction): Promise { + if (!this.adapter || action.conversation.kind === "private") return false; + const command = delegationCommandFromAction(action); + if (!command) return false; + const message: ChannelInboundMessage = { + kind: "message", + channel: action.channel, + updateId: action.updateId, + messageId: action.messageId ?? action.actionId, + text: action.actionData, + attachments: [], + conversation: action.conversation, + sender: action.sender, + metadata: action.metadata, + }; + const handled = await this.handleDelegationMessage(message, command); + await this.adapter.answerAction(action.actionId, { text: handled ? "Delegation action handled." : "Delegation action was ignored or stale." }); + return handled; + } + + private async handleDelegationMessage(message: ChannelInboundMessage, command: NonNullable>): Promise { + const discordConfig = this.config.discord; + if (!this.adapter || !discordConfig?.delegation?.enabled || !discordConfig.sharedRoom?.enabled || !discordConfig.allowGuildChannels || message.conversation.kind === "private") return false; + const createSourceScoped = command.kind === "create" && command.target.kind === "capability" ? this.isDiscordDelegationSourceScopedToLocal(message) : undefined; + if (command.kind === "create") { + const target = resolveSharedRoomMachineTarget({ selector: command.target.kind === "machine" ? command.target.machineId : "", localMachine: this.sharedRoomMachineIdentity() }); + if (command.target.kind === "machine" && target.kind !== "local") return false; + } + const pairedBindings = await this.store.getChannelBindingsForConversation(DISCORD_CHANNEL, message.conversation.id, this.instanceId); + if (pairedBindings.length === 0) return false; + const room = delegationRoomFromMessage(message, this.instanceId); + const duplicate = await this.store.rememberDelegationEvent(delegationIngressEventKey({ message, room, command })); + const decision = await evaluateDelegationIngress({ + command, + message, + policy: { ...discordConfig.delegation, localCapabilities: [...(this.config.machineCapabilities ?? []), ...(discordConfig.delegation.localCapabilities ?? [])] }, + room, + localMachineId: this.config.machineId ?? "local", + localMachineLabel: this.config.machineDisplayName, + localBotUserId: discordConfig.applicationId ?? discordConfig.clientId, + isAuthorizedHuman: isDiscordIdentityAllowed(message.sender, discordConfig), + eventAlreadyHandled: duplicate, + requireCapabilityCreateSourceScope: true, + createSourceScoped, + lookup: { + get: (taskId) => this.store.getDelegationTask(taskId, { runningTimeoutMs: discordConfig.delegation?.runningTimeoutMs }), + list: (options) => this.store.listDelegationTasks({ ...options, runningTimeoutMs: discordConfig.delegation?.runningTimeoutMs }), + }, + eligibleRoutes: this.routes.size === 1 ? [...this.routes.values()] : [], + }); + + switch (decision.kind) { + case "ignore": + if (decision.reason === "not-delegation" || decision.reason === "self-authored" || decision.reason === "not-eligible") return false; + if (decision.message && isDiscordIdentityAllowed(message.sender, discordConfig)) await this.sendText(message, decision.message); + return true; + case "reject": + await this.sendText(message, decision.message); + return true; + case "render-task": + await this.store.upsertDelegationTask(decision.task); + await this.sendDelegationTaskCard(message, decision.task); + return true; + case "status": + case "history": + await this.sendText(message, decision.text); + return true; + case "approve": + case "cancel": + case "decline": + await this.persistDelegationTaskMutation(message, decision.task); + return true; + case "claim": + await this.startClaimedDelegationTask(message, decision.task, decision.prompt, decision.requiresHuman); + return true; + } + } + + private async persistDelegationTaskMutation(message: ChannelInboundMessage, task: DelegationTaskRecord): Promise { + const result = await this.store.tryUpsertDelegationTask(task); + if (!result.applied) { + await this.sendText(message, `Delegation task ${task.id} changed before this action could be applied.`); + await this.sendDelegationTaskCard(message, result.task); + return undefined; + } + await this.sendDelegationTaskCard(message, result.task); + return result.task; + } + + private async startClaimedDelegationTask(message: ChannelInboundMessage, task: DelegationTaskRecord, prompt: string, requiresHuman: boolean): Promise { + if (!this.adapter) return; + if (requiresHuman) { + await this.sendText(message, `Delegation task ${task.id} requires human approval before execution.`); + return; + } + const route = task.claimedBy?.sessionKey ? this.routes.get(task.claimedBy.sessionKey) : this.routes.size === 1 ? [...this.routes.values()][0] : undefined; + if (route && this.activeDelegationTaskBySessionKey.has(route.sessionKey)) { + const blocked = transitionDelegationTask(task, { kind: "block", reason: "The target session already has active delegated work." }); + const next = blocked.ok ? blocked.task : task; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + return; + } + if (!route) { + const blocked = transitionDelegationTask(task, { kind: "block", reason: "No eligible online local session is available." }); + const next = blocked.ok ? blocked.task : task; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + return; + } + const availability = probeRouteAvailability(route); + if (availability.kind === "unavailable" || !availability.idle) { + const blocked = transitionDelegationTask(task, { kind: "block", reason: availability.kind === "unavailable" ? routeActionDisplayMessage(availability) : "The target session is busy; delegated work can only be claimed while the target session is idle." }); + const next = blocked.ok ? blocked.task : task; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + return; + } + const persisted = await this.store.tryUpsertDelegationTask(task); + if (!persisted.applied) { + await this.sendText(message, `Delegation task ${task.id} changed before this claim could be applied.`); + await this.sendDelegationTaskCard(message, persisted.task); + return; + } + const claimedTask = persisted.task; + const outcome = await deliverRoutePrompt(route, { + content: prompt, + deliverAs: this.config.busyDeliveryMode === "steer" ? "steer" : "followUp", + onCommit: async () => { + route.lastActivityAt = Date.now(); + this.activeDelegationTaskBySessionKey.set(route.sessionKey, claimedTask.id); + }, + }); + if (outcome.kind === "unavailable") { + const blocked = transitionDelegationTask(claimedTask, { kind: "block", reason: routeActionDisplayMessage(outcome) }); + const next = blocked.ok ? blocked.task : claimedTask; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + return; + } + if (outcome.kind === "failed") { + const blocked = transitionDelegationTask(claimedTask, { kind: "block", reason: routeActionDisplayMessage(outcome) }); + const next = blocked.ok ? blocked.task : claimedTask; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + return; + } + if (outcome.kind !== "success") return; + const started = transitionDelegationTask(claimedTask, { kind: "start", summary: `Started in ${route.sessionLabel}.` }); + const next = started.ok ? started.task : claimedTask; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + } + + private async finishActiveDelegationTask(route: SessionRoute, status: "completed" | "failed" | "aborted"): Promise { + if (!this.adapter) return false; + const taskId = this.activeDelegationTaskBySessionKey.get(route.sessionKey); + if (!taskId) return false; + this.activeDelegationTaskBySessionKey.delete(route.sessionKey); + const task = await this.store.getDelegationTask(taskId); + if (!task) return false; + const summary = summarizeTextDeterministically(route.notification.lastAssistantText ?? route.notification.lastFailure ?? status, 320); + const transition = status === "completed" + ? transitionDelegationTask(task, { kind: "complete", summary: summary || "Completed." }) + : transitionDelegationTask(task, { kind: "fail", reason: summary || `Task ${status}.` }); + const next = transition.ok ? transition.task : task; + await this.store.upsertDelegationTask(next); + await this.adapter.sendText({ channel: DISCORD_CHANNEL, conversationId: next.room.conversationId, userId: "delegation" }, renderDelegationTaskCard(next, { commandPrefix: "relay task" }).text); + return true; + } + + private async sendDelegationTaskCard(message: ChannelInboundMessage, task: DelegationTaskRecord): Promise { + const card = renderDelegationTaskCard(task, { commandPrefix: "relay task" }); + await this.sendText(message, card.text, delegationTaskActionButtons(card.actions)); + } + private async handlePairing(message: ChannelInboundMessage, code: string): Promise { if (!this.config.discord) return; if (this.isPairingAttemptThrottled(message)) { @@ -983,8 +1182,8 @@ export class DiscordRuntime { await this.adapter?.answerAction(action.actionId, { text: "Action received." }); } - private async sendText(message: ChannelInboundMessage, text: string): Promise { - await this.adapter?.sendText({ channel: DISCORD_CHANNEL, conversationId: message.conversation.id, userId: message.sender.userId }, text); + private async sendText(message: ChannelInboundMessage, text: string, buttons?: ChannelButtonLayout): Promise { + await this.adapter?.sendText({ channel: DISCORD_CHANNEL, conversationId: message.conversation.id, userId: message.sender.userId }, text, { buttons }); } private pairingAttemptKey(message: ChannelInboundMessage): string { diff --git a/extensions/relay/adapters/slack/adapter.ts b/extensions/relay/adapters/slack/adapter.ts index 6c2435b..dba8d1c 100644 --- a/extensions/relay/adapters/slack/adapter.ts +++ b/extensions/relay/adapters/slack/adapter.ts @@ -101,7 +101,7 @@ export interface SlackPostMessagePayload { channel: string; text: string; threadTs?: string; - blocks?: SlackButtonElement[][]; + blocks?: SlackBlock[]; } export interface SlackUploadFilePayload { @@ -113,10 +113,23 @@ export interface SlackUploadFilePayload { threadTs?: string; } +export type SlackBlock = SlackSectionBlock | SlackActionsBlock; + +export interface SlackSectionBlock { + type: "section"; + text: { type: "plain_text"; text: string; emoji?: true }; +} + +export interface SlackActionsBlock { + type: "actions"; + elements: SlackButtonElement[]; +} + export interface SlackButtonElement { type: "button"; text: string; value: string; + actionId?: string; style?: "primary" | "danger"; } @@ -182,11 +195,11 @@ export class SlackChannelAdapter implements ChannelAdapter { async sendText(address: ChannelRouteAddress, text: string, options?: { buttons?: ChannelButtonLayout }): Promise { const threadTs = slackThreadTs(address); - for (const chunk of channelTextChunks(this, text || " ")) { - await this.api.postMessage({ channel: address.conversationId, text: chunk, threadTs }); - } - if (options?.buttons && options.buttons.length > 0) { - await this.api.postMessage({ channel: address.conversationId, text: "Actions:", threadTs, blocks: slackBlocksForButtons(options.buttons) }); + const chunks = channelTextChunks(this, text || " "); + for (const [index, chunk] of chunks.entries()) { + const isLast = index === chunks.length - 1; + const blocks = isLast && options?.buttons && options.buttons.length > 0 ? slackBlocksForTextAndButtons(chunk, options.buttons) : undefined; + await this.api.postMessage({ channel: address.conversationId, text: chunk, threadTs, blocks }); } } @@ -289,16 +302,18 @@ export function slackEnvelopeToChannelEvent(envelope: SlackEnvelope, config: Sla const action = envelope.actions?.[0]; const channelId = envelope.channel?.id ?? ""; const user = envelope.user ?? { id: "unknown" }; + const messageTs = envelope.message?.ts; + const threadTs = pickSlackThreadTs(messageTs, envelope.message?.thread_ts, slackConversationFromId(channelId)); return { kind: "action", channel: SLACK_CHANNEL, updateId: envelope.trigger_id ?? `${channelId}:${envelope.message?.ts ?? Date.now()}`, actionId: buildSlackActionId({ channelId, userId: user.id, responseUrl: envelope.response_url, triggerId: envelope.trigger_id }), - messageId: envelope.message?.ts, + messageId: messageTs ?? `${channelId}:${Date.now()}`, actionData: action?.value ?? action?.action_id ?? "", conversation: slackConversationFromId(channelId), sender: slackIdentity(user.id, user.username ?? user.name, user.team_id ?? envelope.team?.id), - metadata: { teamId: envelope.team?.id, threadTs: envelope.message?.thread_ts ?? envelope.message?.ts }, + metadata: { teamId: user.team_id ?? envelope.team?.id, threadTs }, }; } if (!envelope.event) throw new Error("Slack envelope does not contain a supported event."); @@ -345,19 +360,27 @@ function sanitizeSlackSlashText(text: string | undefined): string { export function slackEventToChannelEvent(event: SlackMessageEvent, config: Pick): ChannelInboundMessage | undefined { if (!event.user || event.bot_id || (event.subtype && event.subtype !== "file_share")) return undefined; const teamId = event.team; + const messageTs = event.ts; + const conversation = slackConversation(event.channel, event.channel_type); return { kind: "message", channel: SLACK_CHANNEL, - updateId: event.ts, - messageId: event.ts, + updateId: messageTs ?? "unknown", + messageId: messageTs ?? "unknown", text: event.text ?? "", attachments: (event.files ?? []).map((file) => slackFileToInboundFile(file, config)), - conversation: slackConversation(event.channel, event.channel_type), + conversation, sender: slackIdentity(event.user, event.username, teamId), - metadata: { teamId, threadTs: event.thread_ts ?? event.ts }, + metadata: { teamId, threadTs: pickSlackThreadTs(messageTs, event.thread_ts, conversation) }, }; } +function pickSlackThreadTs(messageTs: string | undefined, threadTs: string | undefined, conversation?: ChannelConversation): string | undefined { + if (typeof threadTs === "string" && threadTs && threadTs !== messageTs) return threadTs; + if (conversation?.kind === "private" && messageTs) return messageTs; + return undefined; +} + function slackThreadTs(address: ChannelRouteAddress): string | undefined { const value = (address as ChannelRouteAddress & { threadTs?: unknown }).threadTs; return typeof value === "string" && value ? value : undefined; @@ -404,13 +427,24 @@ function slackFileToInboundFile(file: SlackFilePayload, config: Pick row.map((button) => ({ - type: "button", - text: button.label, - value: button.actionData, - style: button.style === "primary" ? "primary" : button.style === "danger" ? "danger" : undefined, - }))); +function slackBlocksForTextAndButtons(text: string, layout: ChannelButtonLayout): SlackBlock[] { + return [ + { type: "section", text: { type: "plain_text", text: text || " ", emoji: true } }, + ...slackBlocksForButtons(layout), + ]; +} + +function slackBlocksForButtons(layout: ChannelButtonLayout): SlackActionsBlock[] { + return layout.map((row) => ({ + type: "actions", + elements: row.map((button) => ({ + type: "button", + text: button.label, + value: button.actionData, + actionId: button.actionData, + style: button.style === "primary" ? "primary" : button.style === "danger" ? "danger" : undefined, + })), + })); } export interface SlackActionTarget { diff --git a/extensions/relay/adapters/slack/live-client.ts b/extensions/relay/adapters/slack/live-client.ts index dc0417e..5f5f49e 100644 --- a/extensions/relay/adapters/slack/live-client.ts +++ b/extensions/relay/adapters/slack/live-client.ts @@ -222,16 +222,30 @@ export class SlackLiveOperations implements SlackApiOperations { } private async callSlackApi(method: string, token: string, body: Record): Promise> { - const response = await fetch(`https://slack.com/api/${method}`, { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/x-www-form-urlencoded; charset=utf-8", - }, - body: formEncode(removeUndefined(body)), - }); + let response: Response; + try { + response = await fetch(`https://slack.com/api/${method}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/x-www-form-urlencoded; charset=utf-8", + }, + body: formEncode(removeUndefined(body)), + }); + } catch (error) { + const message = redactSecrets(error instanceof Error ? error.message : String(error)); + this.debug(`Slack API ${method} request failed: ${message}`); + throw new Error(`Slack API ${method} request failed: ${message}`); + } if (!response.ok) throw new Error(`Slack API ${method} failed with HTTP ${response.status}.`); - const payload = await response.json() as unknown; + let payload: unknown; + try { + payload = await response.json() as unknown; + } catch (error) { + const message = redactSecrets(error instanceof Error ? error.message : String(error)); + this.debug(`Slack API ${method} returned invalid JSON: ${message}`); + throw new Error(`Slack API ${method} returned invalid JSON: ${message}`); + } if (!isRecord(payload)) throw new Error(`Slack API ${method} returned a non-object response.`); if (payload.ok === false) { const error = typeof payload.error === "string" ? payload.error : "unknown_error"; @@ -267,7 +281,7 @@ function socketPayloadToSlackEnvelope(envelope: SlackSocketModeEnvelope): SlackE if (!isRecord(envelope)) return undefined; const payload = envelope.payload; if (!isRecord(payload)) return undefined; - const envelopeType = stringField(envelope, "type") ?? stringField(payload, "type"); + const envelopeType = stringField(payload, "type") ?? stringField(envelope, "type"); if (envelopeType === "event_callback") { return { type: "event_callback", @@ -298,16 +312,20 @@ function slackSocketSlashCommandEnvelope(payload: Record): Slac }; } -function slackBlocks(rows: SlackPostMessagePayload["blocks"]): unknown[] | undefined { - return rows?.map((row) => ({ - type: "actions", - elements: row.map((button) => ({ - type: "button", - text: { type: "plain_text", text: button.text }, - value: button.value, - style: button.style, - })), - })); +function slackBlocks(blocks: SlackPostMessagePayload["blocks"]): unknown[] | undefined { + return blocks?.map((block) => { + if (block.type === "section") return block; + return { + type: "actions", + elements: block.elements.map((button) => ({ + type: "button", + text: { type: "plain_text", text: button.text }, + action_id: button.actionId, + value: button.value, + style: button.style, + })), + }; + }); } function removeUndefined(input: Record): Record { diff --git a/extensions/relay/adapters/slack/runtime.ts b/extensions/relay/adapters/slack/runtime.ts index 3820f6e..0f19bc6 100644 --- a/extensions/relay/adapters/slack/runtime.ts +++ b/extensions/relay/adapters/slack/runtime.ts @@ -1,10 +1,11 @@ import { appendFileSync } from "node:fs"; -import type { ChannelInboundAction, ChannelInboundEvent, ChannelInboundMessage, ChannelOutboundFile, ChannelRouteAddress } from "../../core/channel-adapter.js"; +import type { ChannelButtonLayout, ChannelInboundAction, ChannelInboundEvent, ChannelInboundMessage, ChannelOutboundFile, ChannelRouteAddress } from "../../core/channel-adapter.js"; import type { ChannelPersistedBindingRecord, LatestTurnImage, PairingApprovalDecision, ProgressMode, SessionRoute, TelegramTunnelConfig } from "../../core/types.js"; import { redactSecrets } from "../../config/setup.js"; import { completeSlackPairing } from "../channel-pairing.js"; import { TunnelStateStore } from "../../state/tunnel-store.js"; import { buildHelpText, commandAllowsWhilePaused, normalizeAliasArg, parseRemoteCommandInvocation } from "../../commands/remote.js"; +import { delegationTaskActionButtons, parseDelegationInvocation, renderDelegationTaskCard } from "../../commands/delegation.js"; import { formatFullOutput, formatLatestImageEmptyMessage, formatRelayRecentActivity, formatRelayStatusForRoute, formatSessionSelectorError, formatSummaryOutput, sessionEntryForRoute } from "../../formatting/presenters.js"; import { formatSessionList, resolveSessionSelector, resolveSessionTargetArgs, type SessionListEntry } from "../../core/session-selection.js"; import { displayProgressMode, formatProgressUpdate, normalizeProgressMode, progressIntervalMsFor, progressModeFor, shouldSendNonTerminalProgress } from "../../notifications/progress.js"; @@ -16,6 +17,8 @@ import { authorityOutcomeAllowsDelivery, bindingAuthorityDiagnostic, channelDest import { formatRelayLifecycleNotification, type RelayLifecycleEventKind } from "../../notifications/lifecycle.js"; import { SlackChannelAdapter, isSlackIdentityAllowed, slackEnvelopeToChannelEvent, slackEventToChannelEvent, slackMentionedUserIds, type SlackApiOperations, type SlackAuthTestResult, type SlackEnvelope, type SlackMessageEvent } from "./adapter.js"; import { createSlackLiveOperations, type SlackMessageEventFromHistory } from "./live-client.js"; +import { delegationCommandFromAction, delegationIngressEventKey, delegationRoomFromMessage, evaluateDelegationIngress, isPeerBotIdentity } from "../../core/agent-delegation-runtime.js"; +import { transitionDelegationTask, type DelegationTaskRecord } from "../../core/agent-delegation.js"; const SLACK_CHANNEL = "slack" as const; const SLACK_HELP_TEXT = buildHelpText({ @@ -57,11 +60,12 @@ export class SlackRuntime { private readonly activeSessionByConversationUser = new Map(); private historyPollTimer?: ReturnType; private historyPollInFlight = false; - private latestHistoryTs = (Date.now() / 1_000).toFixed(6); + private latestHistoryTs = initialSlackHistoryTimestamp(); private readonly seenEventKeys = new Map(); private readonly consumedResponseUrls = new Map(); private readonly thinkingReactions = new Map(); private readonly progressStates = new Map; timer?: ReturnType; lastSentAt?: number }>(); + private readonly activeDelegationTaskBySessionKey = new Map(); private botIdentity?: SlackAuthTestResult; private started = false; private startPromise?: Promise; @@ -106,7 +110,8 @@ export class SlackRuntime { throw new Error(this.lastError); } if (this.startPromise) return this.startPromise; - this.startPromise = this.initializeRuntime() + this.startPromise = this.store.markInFlightDelegationTasksStaleAfterRestart() + .then(() => this.initializeRuntime()) .then(() => this.operations!.startSocketMode!(async (envelope) => this.handleEnvelope(envelope))) .then(() => { this.started = true; @@ -185,6 +190,7 @@ export class SlackRuntime { async notifyTurnCompleted(route: SessionRoute, status: "completed" | "failed" | "aborted"): Promise { await this.stopThinkingReaction(route.sessionKey); this.clearProgressState(route); + if (await this.finishActiveDelegationTask(route, status)) return; if (!this.adapter) return; const binding = await this.activeBindingForRoute(route, { includePaused: true }); if (!binding || binding.paused && status === "completed") return; @@ -345,6 +351,10 @@ export class SlackRuntime { if (!this.adapter || event.channel !== SLACK_CHANNEL) return; try { if (event.kind === "action") { + if (actionFromDelegationSurface(event)) { + await this.handleDelegationAction(event); + return; + } await this.handleAction(event); return; } @@ -382,6 +392,34 @@ export class SlackRuntime { await this.adapter.answerAction(action.actionId, { text: "Slack action received." }); } + private async handleDelegationAction(action: ChannelInboundAction): Promise { + const slackConfig = this.configForInstance(); + if (!this.adapter || action.conversation.kind === "private") return; + const command = delegationCommandFromAction(action); + if (!command) return; + const taskId = command.kind === "create" ? undefined : command.taskId; + if (taskId) { + const existing = await this.store.getDelegationTask(taskId, { runningTimeoutMs: slackConfig?.delegation?.runningTimeoutMs }); + if (!existing) { + await this.adapter.answerAction(action.actionId, { text: "Delegation action was ignored or stale." }); + return; + } + } + const message: ChannelInboundMessage = { + kind: "message", + channel: action.channel, + updateId: action.updateId, + messageId: action.messageId ?? action.actionId, + text: action.actionData, + attachments: [], + conversation: action.conversation, + sender: action.sender, + metadata: action.metadata, + }; + const handled = await this.handleDelegationMessage(message, command); + await this.adapter.answerAction(action.actionId, { text: handled ? "Delegation action handled." : "Delegation action was ignored or stale." }); + } + private async handleMessage(message: ChannelInboundMessage): Promise { const slackConfig = this.configForInstance(); const localBotUserId = this.localBotUserId(); @@ -391,9 +429,25 @@ export class SlackRuntime { await this.handlePairing(message, pairingCode); return; } - if (!slackConfig || !isSlackIdentityAllowed(message.sender, slackConfig)) return; + const delegatedCommand = parseDelegationInvocation(message.text, { prefixes: ["relay", "pirelay"] }) ?? parseDelegationInvocation(stripLeadingSlackMentions(message.text), { prefixes: ["relay", "pirelay"] }); + if (isPeerBotIdentity(message.sender) && !delegatedCommand) return; + const delegationSourceScoped = delegatedCommand?.kind === "create" ? this.isSlackDelegationSourceScopedToLocal(message) : false; const routedMessage = await this.applySharedRoomPreRouting(message); - if (!routedMessage) return; + if (!routedMessage) { + if (delegatedCommand) return; + return; + } + if (delegatedCommand && routedMessage.conversation.kind !== "private") { + if (isPeerBotIdentity(message.sender) || (slackConfig && isSlackIdentityAllowed(message.sender, slackConfig))) { + await this.findSlackBinding(routedMessage); + await this.livePreseededBinding(routedMessage, { setActiveSelection: false }); + } + const scopedMessage = delegationSourceScoped ? { ...routedMessage, metadata: { ...routedMessage.metadata, delegationSourceScoped: true } } : routedMessage; + if (await this.handleDelegationMessage(scopedMessage, delegatedCommand)) return; + return; + } + if (isPeerBotIdentity(message.sender)) return; + if (!slackConfig || !isSlackIdentityAllowed(message.sender, slackConfig)) return; const binding = await this.findSlackBinding(routedMessage) ?? await this.livePreseededBinding(routedMessage); if (!binding) { await this.sendText(message, message.conversation.kind === "private" @@ -452,12 +506,175 @@ export class SlackRuntime { if (active?.machineId && active.machineId !== (this.config.machineId ?? "local")) return undefined; if (!active) { const bindings = await this.store.getChannelBindingsForConversation(SLACK_CHANNEL, message.conversation.id, this.instanceId); - if (bindings.length === 0 || !command) return undefined; + if (bindings.length === 0) { + if (process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING === "true" && command) return message; + return undefined; + } + if (!command) return undefined; } } return hasLocalMention ? { ...message, text: stripLeadingSlackMentions(message.text) } : message; } + + private async handleDelegationMessage(message: ChannelInboundMessage, command: NonNullable>): Promise { + const slackConfig = this.configForInstance(); + if (!this.adapter || !slackConfig?.delegation?.enabled || !slackConfig.sharedRoom?.enabled || !slackConfig.allowChannelMessages) return false; + if (command.kind === "create" && command.target.kind === "machine" && !isLocalSlackMachineSelector(command.target.machineId, this.config, slackConfig)) { + return false; + } + if ("taskId" in command && command.taskId) { + const existing = await this.store.getDelegationTask(command.taskId, { runningTimeoutMs: slackConfig.delegation?.runningTimeoutMs }); + if (!existing) return false; + } + const pairedBindings = await this.store.getChannelBindingsForConversation(SLACK_CHANNEL, message.conversation.id, this.instanceId); + const canHandleWithoutBinding = process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING === "true" && command.kind === "create"; + if (pairedBindings.length === 0 && !canHandleWithoutBinding) return false; + const room = delegationRoomFromMessage(message, this.instanceId); + const duplicate = await this.store.rememberDelegationEvent(delegationIngressEventKey({ message, room, command })); + const decision = await evaluateDelegationIngress({ + command, + message, + policy: { ...slackConfig.delegation, localCapabilities: [...(this.config.machineCapabilities ?? []), ...(slackConfig.delegation.localCapabilities ?? [])] }, + room, + localMachineId: this.config.machineId ?? "local", + localMachineLabel: this.config.machineDisplayName, + localBotUserId: this.localBotUserId(), + isAuthorizedHuman: isSlackIdentityAllowed(message.sender, slackConfig), + eventAlreadyHandled: duplicate, + requireCapabilityCreateSourceScope: true, + createSourceScoped: message.metadata?.delegationSourceScoped === true, + lookup: { + get: (taskId) => this.store.getDelegationTask(taskId, { runningTimeoutMs: slackConfig.delegation?.runningTimeoutMs }), + list: (options) => this.store.listDelegationTasks({ ...options, runningTimeoutMs: slackConfig.delegation?.runningTimeoutMs }), + }, + eligibleRoutes: this.routes.size === 1 ? [...this.routes.values()] : this.routes.size === 0 ? undefined : [], + }); + switch (decision.kind) { + case "ignore": + if (decision.reason === "not-delegation" || decision.reason === "self-authored" || decision.reason === "not-eligible") return false; + if (decision.message && isSlackIdentityAllowed(message.sender, slackConfig)) await this.sendText(message, decision.message); + return true; + case "reject": + await this.sendText(message, decision.message); + return true; + case "render-task": + await this.store.upsertDelegationTask(decision.task); + await this.sendDelegationTaskCard(message, decision.task); + return true; + case "status": + case "history": + await this.sendText(message, decision.text); + return true; + case "approve": + case "cancel": + case "decline": + await this.persistDelegationTaskMutation(message, decision.task); + return true; + case "claim": + await this.startClaimedDelegationTask(message, decision.task, decision.prompt, decision.requiresHuman); + return true; + } + } + + private async persistDelegationTaskMutation(message: ChannelInboundMessage, task: DelegationTaskRecord): Promise { + const result = await this.store.tryUpsertDelegationTask(task); + if (!result.applied) { + await this.sendText(message, `Delegation task ${task.id} changed before this action could be applied.`); + await this.sendDelegationTaskCard(message, result.task); + return undefined; + } + await this.sendDelegationTaskCard(message, result.task); + return result.task; + } + + private async startClaimedDelegationTask(message: ChannelInboundMessage, task: DelegationTaskRecord, prompt: string, requiresHuman: boolean): Promise { + if (requiresHuman) { + await this.sendText(message, `Delegation task ${task.id} requires human approval before execution.`); + return; + } + const route = task.claimedBy?.sessionKey ? this.routes.get(task.claimedBy.sessionKey) : this.routes.size === 1 ? [...this.routes.values()][0] : undefined; + if (route && this.activeDelegationTaskBySessionKey.has(route.sessionKey)) { + const blocked = transitionDelegationTask(task, { kind: "block", reason: "The target session already has active delegated work." }); + const next = blocked.ok ? blocked.task : task; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + return; + } + if (!route) { + const blocked = transitionDelegationTask(task, { kind: "block", reason: "No eligible online local session is available." }); + const next = blocked.ok ? blocked.task : task; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + return; + } + const availability = probeRouteAvailability(route); + if (availability.kind === "unavailable" || !availability.idle) { + const blocked = transitionDelegationTask(task, { kind: "block", reason: availability.kind === "unavailable" ? routeActionDisplayMessage(availability) : "The target session is busy; delegated work can only be claimed while the target session is idle." }); + const next = blocked.ok ? blocked.task : task; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + return; + } + const persisted = await this.store.tryUpsertDelegationTask(task); + if (!persisted.applied) { + await this.sendText(message, `Delegation task ${task.id} changed before this claim could be applied.`); + await this.sendDelegationTaskCard(message, persisted.task); + return; + } + const claimedTask = persisted.task; + const outcome = await deliverRoutePrompt(route, { + content: prompt, + deliverAs: this.config.busyDeliveryMode === "steer" ? "steer" : "followUp", + onCommit: async () => { + route.lastActivityAt = Date.now(); + this.activeDelegationTaskBySessionKey.set(route.sessionKey, claimedTask.id); + }, + }); + if (outcome.kind === "unavailable") { + const blocked = transitionDelegationTask(claimedTask, { kind: "block", reason: routeActionDisplayMessage(outcome) }); + const next = blocked.ok ? blocked.task : claimedTask; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + return; + } + if (outcome.kind === "failed") { + const blocked = transitionDelegationTask(claimedTask, { kind: "block", reason: routeActionDisplayMessage(outcome) }); + const next = blocked.ok ? blocked.task : claimedTask; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + return; + } + if (outcome.kind !== "success") return; + const started = transitionDelegationTask(claimedTask, { kind: "start", summary: `Started in ${route.sessionLabel}.` }); + const next = started.ok ? started.task : claimedTask; + await this.store.upsertDelegationTask(next); + await this.sendDelegationTaskCard(message, next); + } + + private async finishActiveDelegationTask(route: SessionRoute, status: "completed" | "failed" | "aborted"): Promise { + if (!this.adapter) return false; + const taskId = this.activeDelegationTaskBySessionKey.get(route.sessionKey); + if (!taskId) return false; + this.activeDelegationTaskBySessionKey.delete(route.sessionKey); + const task = await this.store.getDelegationTask(taskId); + if (!task) return false; + const summary = route.notification.lastAssistantText ?? route.notification.lastFailure ?? status; + const transition = status === "completed" + ? transitionDelegationTask(task, { kind: "complete", summary }) + : transitionDelegationTask(task, { kind: "fail", reason: summary }); + const next = transition.ok ? transition.task : task; + await this.store.upsertDelegationTask(next); + const card = renderDelegationTaskCard(next, { commandPrefix: "relay task" }); + await this.adapter.sendText({ channel: SLACK_CHANNEL, conversationId: next.room.conversationId, userId: "delegation", ...(next.room.threadId ? { threadTs: next.room.threadId } : {}) } as ChannelRouteAddress, card.text, { buttons: delegationTaskActionButtons(card.actions) }); + return true; + } + + private async sendDelegationTaskCard(message: ChannelInboundMessage, task: DelegationTaskRecord): Promise { + const card = renderDelegationTaskCard(task, { commandPrefix: "relay task" }); + await this.sendText(message, card.text, delegationTaskActionButtons(card.actions)); + } + private async handleBoundMessage(message: ChannelInboundMessage, binding: ChannelPersistedBindingRecord, route: SessionRoute): Promise { const command = parseSlackCommand(message.text); if (command) { @@ -848,20 +1065,20 @@ export class SlackRuntime { ); } - private async livePreseededBinding(message: ChannelInboundMessage): Promise { - if (process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING !== "true" || message.conversation.kind === "private" || this.routes.size !== 1) return undefined; - const route = [...this.routes.values()][0]; - if (!route) return undefined; + private async livePreseededBinding(message: ChannelInboundMessage, options: { setActiveSelection?: boolean } = {}): Promise { + if (process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING !== "true" || message.conversation.kind === "private") return undefined; + const firstRoute = [...this.routes.values()][0]; const now = new Date().toISOString(); + const routeSeed = `${message.conversation.id}:${message.sender.userId}`; const binding = await this.store.upsertChannelBinding({ channel: SLACK_CHANNEL, instanceId: this.instanceId, conversationId: message.conversation.id, userId: message.sender.userId, - sessionKey: route.sessionKey, - sessionId: route.sessionId, - sessionFile: route.sessionFile, - sessionLabel: route.sessionLabel, + sessionKey: firstRoute?.sessionKey ?? `live-preseed:${routeSeed}`, + sessionId: firstRoute?.sessionId ?? `live-preseed:${routeSeed}`, + sessionFile: firstRoute?.sessionFile, + sessionLabel: firstRoute?.sessionLabel ?? "shared-room", boundAt: now, lastSeenAt: now, identity: { username: message.sender.username, displayName: message.sender.displayName, metadata: message.sender.metadata }, @@ -869,7 +1086,7 @@ export class SlackRuntime { }); this.ownedBindingSessionKeys.add(binding.sessionKey); this.recentBindingBySessionKey.set(binding.sessionKey, binding); - await this.setActiveSelection(message, binding.sessionKey); + if (options.setActiveSelection !== false) await this.setActiveSelection(message, binding.sessionKey); return binding; } @@ -893,6 +1110,11 @@ export class SlackRuntime { } } + private isSlackDelegationSourceScopedToLocal(message: ChannelInboundMessage): boolean { + const localBotUserId = this.localBotUserId(); + return Boolean(localBotUserId && slackMentionedUserIds(message.text).includes(localBotUserId)); + } + private async activeSelectionRecordForMessage(message: Pick): Promise<{ sessionKey: string; machineId?: string } | undefined> { const snapshot = await this.store.loadBindingAuthoritySnapshot(); const key = this.activeSelectionKey(message); @@ -938,7 +1160,7 @@ export class SlackRuntime { return undefined; } - private async sendText(message: Pick & { metadata?: Record }, text: string): Promise { + private async sendText(message: Pick & { metadata?: Record }, text: string, buttons?: ChannelButtonLayout): Promise { const responseUrl = typeof message.metadata?.responseUrl === "string" ? message.metadata.responseUrl : undefined; if (responseUrl && this.operations?.postResponse && !this.wasResponseUrlConsumed(responseUrl)) { this.rememberConsumedResponseUrl(responseUrl); @@ -949,7 +1171,7 @@ export class SlackRuntime { debugSlackRuntime(`Slack response_url delivery failed: ${safeSlackRuntimeError(error)}`); } } - await this.adapter?.sendText(slackAddress(message), text); + await this.adapter?.sendText(slackAddress(message), text, { buttons }); } private wasResponseUrlConsumed(responseUrl: string): boolean { @@ -1149,20 +1371,23 @@ function hasHistoryReader(operations: SlackApiOperations | undefined): operation function slackEventToChannelEventIncludingBotMessages(event: SlackMessageEvent | SlackMessageEventFromHistory, config: Parameters[1]): ChannelInboundMessage | undefined { if (!event.bot_id) return slackEventToChannelEvent(event, config); + const senderUser = event.user; if (!senderUser || event.subtype && event.subtype !== "bot_message") return undefined; + const messageTs = event.ts; + const conversation = { + channel: SLACK_CHANNEL, + id: event.channel, + kind: event.channel_type === "im" ? "private" : event.channel_type === "channel" || event.channel_type === "group" ? "channel" : event.channel_type === "mpim" ? "group" : "unknown", + } as const; return { kind: "message", channel: SLACK_CHANNEL, - updateId: event.ts, - messageId: event.ts, + updateId: messageTs, + messageId: messageTs, text: event.text ?? "", attachments: [], - conversation: { - channel: SLACK_CHANNEL, - id: event.channel, - kind: event.channel_type === "im" ? "private" : event.channel_type === "channel" || event.channel_type === "group" ? "channel" : event.channel_type === "mpim" ? "group" : "unknown", - }, + conversation, sender: { channel: SLACK_CHANNEL, userId: senderUser, @@ -1170,10 +1395,16 @@ function slackEventToChannelEventIncludingBotMessages(event: SlackMessageEvent | displayName: event.username, metadata: { teamId: event.team, botId: event.bot_id }, }, - metadata: { teamId: event.team, botId: event.bot_id, liveStubBotMessage: true }, + metadata: { teamId: event.team, botId: event.bot_id, liveStubBotMessage: true, threadTs: pickSlackThreadTs(messageTs, (event as { thread_ts?: string }).thread_ts, conversation.kind === "private") }, }; } +function pickSlackThreadTs(messageTs: string | undefined, threadTs: string | undefined, isPrivateConversation = false): string | undefined { + if (typeof threadTs === "string" && threadTs && threadTs !== messageTs) return threadTs; + if (isPrivateConversation && messageTs) return messageTs; + return undefined; +} + function parseSlackPairingCode(text: string): string | undefined { const trimmed = text.trim(); const explicit = trimmed.match(/^relay\s+pair\s+(\S+)$/i); @@ -1202,15 +1433,34 @@ function stripLeadingSlackMentions(text: string): string { return text.replace(/^(?:\s*<@[A-Z0-9_]+>)+\s*/, ""); } +function actionFromDelegationSurface(action: ChannelInboundAction): boolean { + return !!delegationCommandFromAction(action); +} + function isLocalSlackMachineSelector(selector: string, config: TelegramTunnelConfig, slackConfig: NonNullable): boolean { - const normalized = selector.trim().toLowerCase(); - const aliases = [config.machineId ?? "local", config.machineDisplayName, ...(config.machineAliases ?? []), ...(slackConfig.sharedRoom?.machineAliases ?? [])] + const normalized = normalizeSlackMachineSelector(selector); + const aliases = [ + config.machineId ?? "local", + config.machineDisplayName, + ...(config.machineAliases ?? []), + ...(slackConfig.sharedRoom?.machineAliases ?? []), + ] .filter((value): value is string => Boolean(value)) - .map((value) => value.trim().toLowerCase()) + .map((value) => normalizeSlackMachineSelector(value)) .filter(Boolean); return aliases.includes(normalized); } +function normalizeSlackMachineSelector(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/^@+/, "") + .replace(/[^a-zA-Z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 128); +} + function slackPairingFailureMessage(reason: "wrong-channel" | "unsupported-conversation" | "unauthorized" | "command-mismatch" | "expired"): string { switch (reason) { case "wrong-channel": @@ -1286,6 +1536,12 @@ function safeSlackRuntimeError(error: unknown): string { return redactSecrets(error instanceof Error ? error.message : String(error)); } +function initialSlackHistoryTimestamp(): string { + const configuredOldest = process.env.PI_RELAY_SLACK_HISTORY_OLDEST_TS?.trim(); + if (configuredOldest && Number.isFinite(Number(configuredOldest))) return configuredOldest; + return (Date.now() / 1_000).toFixed(6); +} + function debugSlackRuntime(message: string): void { const path = process.env.PI_RELAY_SLACK_DEBUG_LOG; if (!path) return; diff --git a/extensions/relay/adapters/telegram/actions.ts b/extensions/relay/adapters/telegram/actions.ts index 1a791a3..b2696c7 100644 --- a/extensions/relay/adapters/telegram/actions.ts +++ b/extensions/relay/adapters/telegram/actions.ts @@ -13,7 +13,7 @@ export type TelegramActionCallback = | { kind: "dashboard"; sessionRef: string; action: DashboardAction }; const MAX_BUTTON_LABEL = 56; -const FULL_OUTPUT_ACTION_MIN_CHARS = 320; +const FULL_OUTPUT_ACTION_MIN_CHARS = 2_000; function encodePart(value: string): string { return encodeURIComponent(value); diff --git a/extensions/relay/adapters/telegram/runtime.ts b/extensions/relay/adapters/telegram/runtime.ts index 678aa1d..1412772 100644 --- a/extensions/relay/adapters/telegram/runtime.ts +++ b/extensions/relay/adapters/telegram/runtime.ts @@ -8,7 +8,7 @@ import { authorityOutcomeAllowsDelivery, bindingAuthorityDiagnostic, resolveTele import { BrokerTunnelRuntime } from "../../broker/tunnel-runtime.js"; import { TunnelStateStore } from "../../state/tunnel-store.js"; import { TelegramApiClient } from "./api.js"; -import { TelegramChannelAdapter, telegramCapabilities } from "./adapter.js"; +import { TelegramChannelAdapter, telegramCapabilities, toTelegramKeyboard } from "./adapter.js"; import { advanceGuidedAnswerFlow, buildChoiceInjection, @@ -41,25 +41,30 @@ import type { SessionStatusSnapshot, SetupCache, TelegramBindingMetadata, + PersistedBindingRecord, TelegramDownloadedImage, TelegramInboundCallback, TelegramInboundImageReference, TelegramInboundMessage, TelegramInboundUpdate, + TelegramInlineKeyboard, TelegramPromptContent, TelegramTunnelConfig, TunnelRuntime, TelegramUserSummary, } from "../../core/types.js"; import { HELP_TEXT, commandAllowsWhilePaused, normalizeAliasArg, parseRemoteCommandInvocation } from "../../commands/remote.js"; +import { delegationTaskActionButtons, parseDelegationActionId, parseDelegationCommand, renderDelegationTaskCard, type DelegationActionKind, type DelegationCommand } from "../../commands/delegation.js"; import { redactSecrets } from "../../config/setup.js"; import { telegramBotCommands } from "../../commands/surfaces.js"; import { formatSessionList, resolveSessionSelector, resolveSessionTargetArgs, sessionSourcePrefixForRoute, type SessionListEntry } from "../../core/session-selection.js"; import { DEFAULT_FINAL_OUTPUT_MAX_MESSAGE_CHUNKS, finalOutputMarkdownFile, shouldSendFullFinalOutput } from "../../core/final-output.js"; -import { channelTextChunks } from "../../core/channel-adapter.js"; +import { channelTextChunks, type ChannelInboundMessage } from "../../core/channel-adapter.js"; import { deliverWorkspaceFileToRequester, formatRequesterFileDeliveryResult, parseRemoteSendFileArgs, type RelayFileDeliveryRequester } from "../../core/requester-file-delivery.js"; import { formatFullOutput, formatRelayStatusForRoute, formatSessionSelectorError, formatSummaryOutput } from "../../formatting/presenters.js"; import { commandIntentFromPipeline, runTelegramIngressPipeline, telegramActionFromPipelineResult } from "./middleware.js"; +import { delegationIngressEventKey, delegationRoomFromMessage, evaluateDelegationIngress } from "../../core/agent-delegation-runtime.js"; +import { transitionDelegationTask, type DelegationTaskRecord } from "../../core/agent-delegation.js"; import { appendRecentActivity, displayProgressMode, @@ -156,6 +161,7 @@ export class InProcessTunnelRuntime implements TunnelRuntime { private readonly progressStates = new Map; timer?: ReturnType; lastSentAt?: number }>(); private readonly activeSessionByChatUser = new Map(); private readonly sharedRoomOutputDestinations = new Map(); + private readonly activeDelegationTaskBySessionKey = new Map(); private started = false; private pollingTask?: Promise; private releaseLock?: () => Promise; @@ -178,6 +184,7 @@ export class InProcessTunnelRuntime implements TunnelRuntime { await ensureStateDir(this.config.stateDir); await this.acquireLock(); await this.ensureSetup(); + await this.store.markInFlightDelegationTasksStaleAfterRestart(); await this.registerBotCommandMenu(); this.started = true; this.pollingTask = this.pollLoop(); @@ -234,7 +241,14 @@ export class InProcessTunnelRuntime implements TunnelRuntime { if (route.binding) { const expected = { sessionKey: route.sessionKey, chatId: route.binding.chatId, userId: route.binding.userId, includePaused: true, allowVolatileFallback: true }; const outcome = resolveTelegramBindingAuthority(await this.store.loadBindingAuthoritySnapshot(), expected, route.binding); - route.binding = authorityOutcomeAllowsDelivery(outcome) ? outcome.binding : undefined; + if (authorityOutcomeAllowsDelivery(outcome)) { + route.binding = outcome.binding; + route.actions.clearLocalStatus?.("relay-binding-authority"); + } else if (outcome.kind === "state-unavailable") { + route.actions.setLocalStatus?.("relay-binding-authority", bindingAuthorityDiagnostic(outcome) ?? "Relay state is unavailable; protected messenger delivery was suppressed."); + } else { + route.binding = undefined; + } } if (previousRoute?.binding?.chatId !== route.binding?.chatId && previousRoute?.binding) { this.clearActivityIndicator(previousRoute); @@ -708,14 +722,21 @@ export class InProcessTunnelRuntime implements TunnelRuntime { return; } - const persisted = await this.activeBindingForMessage(message.chat.id, message.user.id); + const bindingSnapshot = await this.store.loadBindingAuthoritySnapshot(); + if (bindingSnapshot.kind === "state-unavailable") { + await this.api.sendPlainText(message.chat.id, "Relay state is temporarily unavailable; retry shortly."); + return; + } + const chatBindings = this.bindingsForChatFromSnapshot(bindingSnapshot.data.bindings, message.chat.id); + const persisted = this.activeBindingForMessageFromBindings(chatBindings, message.chat.id, message.user.id); if (!persisted) { - const revoked = await this.chatUserHasRevokedBinding(message.chat.id, message.user.id); + const revoked = this.chatUserHasRevokedBindingFromBindings(chatBindings, message.user.id); + const chatHasActive = this.chatHasActiveBindingFromBindings(chatBindings); await this.api.sendPlainText( message.chat.id, revoked ? "This Telegram relay binding has been revoked. Pair again from Pi with /relay connect telegram." - : await this.chatHasActiveBinding(message.chat.id) + : chatHasActive ? "Unauthorized Telegram identity for this Pi session." : "This chat is not paired to an active Pi session. Run /relay connect telegram locally first.", ); @@ -805,6 +826,15 @@ export class InProcessTunnelRuntime implements TunnelRuntime { } private async processCallback(callback: TelegramInboundCallback): Promise { + const delegationAction = parseDelegationActionId(callback.data); + if (delegationAction) { + if (!await this.isTelegramDelegationCallbackAuthorized(callback)) return; + const command = delegationCommandFromCallbackAction(delegationAction.kind, delegationAction.taskId); + const handled = await this.handleTelegramDelegationMessage(telegramMessageFromCallback(callback, `task ${delegationAction.kind} ${delegationAction.taskId}`), command); + await this.api.answerCallbackQuery(callback.callbackQueryId, handled ? "Delegation action handled." : "Delegation action was ignored or stale."); + return; + } + const initialPipeline = await runTelegramIngressPipeline(callback, { authorized: false, config: this.config }); const action = telegramActionFromPipelineResult(initialPipeline.result) ?? parseTelegramActionCallbackData(callback.data); if (!action) { @@ -1013,13 +1043,202 @@ export class InProcessTunnelRuntime implements TunnelRuntime { } } + private async isTelegramDelegationCallbackAuthorized(callback: TelegramInboundCallback): Promise { + if (!isTelegramGroupConversation(callback.chat.type)) return true; + if (callback.user.isBot) return true; + if (this.config.allowUserIds.length > 0 && !this.config.allowUserIds.includes(callback.user.id)) { + await this.api.answerCallbackQuery(callback.callbackQueryId, "Unauthorized."); + return false; + } + const entries = await this.sessionEntriesForTelegramUser(callback.user.id); + if (entries.length > 0) return true; + const setup = await this.ensureSetup(); + await this.api.answerCallbackQuery(callback.callbackQueryId, "Pair privately first."); + await this.api.sendPlainText(callback.chat.id, `Pair with this bot in a private Telegram chat first, then use /sessions@${setup.botUsername} from the group.`); + return false; + } + + private async handleTelegramDelegationMessage(message: TelegramInboundMessage, command: NonNullable>): Promise { + if (!this.config.delegation?.enabled) return false; + const channelMessage = this.telegramChannelMessage(message); + const room = delegationRoomFromMessage(channelMessage, "default"); + const duplicate = await this.store.rememberDelegationEvent(delegationIngressEventKey({ message: channelMessage, room, command })); + const decision = await evaluateDelegationIngress({ + command, + message: channelMessage, + policy: { ...this.config.delegation, localCapabilities: [...(this.config.machineCapabilities ?? []), ...(this.config.delegation.localCapabilities ?? [])] }, + room, + localMachineId: this.config.machineId ?? "local", + localMachineLabel: this.config.machineDisplayName, + localBotUserId: String((await this.ensureSetup()).botId), + isAuthorizedHuman: !message.user.isBot && (this.config.allowUserIds.length === 0 || this.config.allowUserIds.includes(message.user.id)), + eventAlreadyHandled: duplicate, + lookup: { + get: (taskId) => this.store.getDelegationTask(taskId, { runningTimeoutMs: this.config.delegation?.runningTimeoutMs }), + list: (options) => this.store.listDelegationTasks({ ...options, runningTimeoutMs: this.config.delegation?.runningTimeoutMs }), + }, + eligibleRoutes: this.routes.size === 1 ? [...this.routes.values()] : [], + }); + switch (decision.kind) { + case "ignore": + if (decision.reason === "not-delegation" || decision.reason === "self-authored" || decision.reason === "not-eligible") return false; + if (decision.message && !message.user.isBot) await this.api.sendPlainText(message.chat.id, decision.message); + return true; + case "reject": + await this.api.sendPlainText(message.chat.id, decision.message); + return true; + case "render-task": + await this.store.upsertDelegationTask(decision.task); + await this.sendTelegramDelegationTaskCard(message, decision.task); + return true; + case "status": + case "history": + await this.api.sendPlainText(message.chat.id, decision.text); + return true; + case "approve": + case "cancel": + case "decline": + await this.persistTelegramDelegationTaskMutation(message, decision.task); + return true; + case "claim": + await this.startClaimedTelegramDelegationTask(message, decision.task, decision.prompt, decision.requiresHuman); + return true; + } + } + + private async persistTelegramDelegationTaskMutation(message: TelegramInboundMessage, task: DelegationTaskRecord): Promise { + const result = await this.store.tryUpsertDelegationTask(task); + if (!result.applied) { + await this.api.sendPlainText(message.chat.id, `Delegation task ${task.id} changed before this action could be applied.`); + await this.sendTelegramDelegationTaskCard(message, result.task); + return undefined; + } + await this.sendTelegramDelegationTaskCard(message, result.task); + return result.task; + } + + private telegramChannelMessage(message: TelegramInboundMessage): ChannelInboundMessage { + return { + kind: "message", + channel: "telegram", + updateId: String(message.messageId), + messageId: String(message.messageId), + text: message.text, + attachments: [], + conversation: { channel: "telegram", id: String(message.chat.id), kind: isTelegramGroupConversation(message.chat.type) ? "group" : "private", title: message.chat.title }, + sender: { + channel: "telegram", + userId: String(message.user.id), + username: message.user.username, + displayName: getTelegramUserLabel(message.user), + firstName: message.user.firstName, + lastName: message.user.lastName, + metadata: { isBot: message.user.isBot === true }, + }, + }; + } + + private async startClaimedTelegramDelegationTask(message: TelegramInboundMessage, task: DelegationTaskRecord, prompt: string, requiresHuman: boolean): Promise { + if (requiresHuman) { + await this.api.sendPlainText(message.chat.id, `Delegation task ${task.id} requires human approval before execution.`); + return; + } + const route = task.claimedBy?.sessionKey ? this.routes.get(task.claimedBy.sessionKey) : this.routes.size === 1 ? [...this.routes.values()][0] : undefined; + if (route && this.activeDelegationTaskBySessionKey.has(route.sessionKey)) { + const blocked = transitionDelegationTask(task, { kind: "block", reason: "The target session already has active delegated work." }); + const next = blocked.ok ? blocked.task : task; + await this.store.upsertDelegationTask(next); + await this.sendTelegramDelegationTaskCard(message, next); + return; + } + if (!route) { + const blocked = transitionDelegationTask(task, { kind: "block", reason: "No eligible online local session is available." }); + const next = blocked.ok ? blocked.task : task; + await this.store.upsertDelegationTask(next); + await this.sendTelegramDelegationTaskCard(message, next); + return; + } + const availability = probeRouteAvailability(route); + if (availability.kind === "unavailable" || !availability.idle) { + const blocked = transitionDelegationTask(task, { kind: "block", reason: availability.kind === "unavailable" ? routeActionDisplayMessage(availability) : "The target session is busy; delegated work can only be claimed while the target session is idle." }); + const next = blocked.ok ? blocked.task : task; + await this.store.upsertDelegationTask(next); + await this.sendTelegramDelegationTaskCard(message, next); + return; + } + const persisted = await this.store.tryUpsertDelegationTask(task); + if (!persisted.applied) { + await this.api.sendPlainText(message.chat.id, `Delegation task ${task.id} changed before this claim could be applied.`); + await this.sendTelegramDelegationTaskCard(message, persisted.task); + return; + } + const claimedTask = persisted.task; + const outcome = await deliverRoutePrompt(route, { + content: prompt, + deliverAs: this.config.busyDeliveryMode === "steer" ? "steer" : "followUp", + onCommit: async () => { + route.lastActivityAt = Date.now(); + this.activeDelegationTaskBySessionKey.set(route.sessionKey, claimedTask.id); + }, + }); + if (outcome.kind === "unavailable") { + const blocked = transitionDelegationTask(claimedTask, { kind: "block", reason: routeActionDisplayMessage(outcome) }); + const next = blocked.ok ? blocked.task : claimedTask; + await this.store.upsertDelegationTask(next); + await this.sendTelegramDelegationTaskCard(message, next); + return; + } + if (outcome.kind === "failed") { + const blocked = transitionDelegationTask(claimedTask, { kind: "block", reason: routeActionDisplayMessage(outcome) }); + const next = blocked.ok ? blocked.task : claimedTask; + await this.store.upsertDelegationTask(next); + await this.sendTelegramDelegationTaskCard(message, next); + return; + } + if (outcome.kind !== "success") return; + const started = transitionDelegationTask(claimedTask, { kind: "start", summary: `Started in ${route.sessionLabel}.` }); + const next = started.ok ? started.task : claimedTask; + await this.store.upsertDelegationTask(next); + await this.sendTelegramDelegationTaskCard(message, next); + } + + private async sendTelegramDelegationTaskCard(message: TelegramInboundMessage, task: DelegationTaskRecord): Promise { + const commandPrefix = await this.delegationTaskCommandPrefixForMessage(message); + const card = renderDelegationTaskCard(task, { commandPrefix }); + await this.sendTextWithKeyboard(message.chat.id, card.text, telegramDelegationKeyboard(card.actions)); + } + + private async finishActiveTelegramDelegationTask(route: SessionRoute, status: "completed" | "failed" | "aborted"): Promise { + const taskId = this.activeDelegationTaskBySessionKey.get(route.sessionKey); + if (!taskId) return false; + this.activeDelegationTaskBySessionKey.delete(route.sessionKey); + const task = await this.store.getDelegationTask(taskId); + if (!task) return false; + const summary = route.notification.lastAssistantText ?? route.notification.lastFailure ?? status; + const transition = status === "completed" + ? transitionDelegationTask(task, { kind: "complete", summary }) + : transitionDelegationTask(task, { kind: "fail", reason: summary }); + const next = transition.ok ? transition.task : task; + await this.store.upsertDelegationTask(next); + const commandPrefix = await this.delegationTaskCommandPrefixForConversationId(next.room.conversationId); + const card = renderDelegationTaskCard(next, { commandPrefix }); + await this.sendTextWithKeyboard(Number(next.room.conversationId), card.text, telegramDelegationKeyboard(card.actions)); + return true; + } + private async handleTelegramGroupSharedRoomCommand(message: TelegramInboundMessage, target: TelegramGroupCommandTarget | undefined): Promise { - if (!target || !["help", "sessions", "use", "to"].includes(target.command)) return false; + const delegationCommand = target ? parseDelegationCommand(target.command, target.args) : undefined; + if (!target || !["help", "sessions", "use", "to"].includes(target.command) && !delegationCommand) return false; if (!target.botUsername) return true; const setup = await this.ensureSetup(); if (normalizeTelegramBotUsername(target.botUsername) !== normalizeTelegramBotUsername(setup.botUsername)) return true; + if (delegationCommand && message.user.isBot) { + if (await this.handleTelegramDelegationMessage(message, delegationCommand)) return true; + return true; + } + if (target.command === "help") { await this.api.sendPlainText(message.chat.id, HELP_TEXT); return true; @@ -1041,6 +1260,11 @@ export class InProcessTunnelRuntime implements TunnelRuntime { ? activeSelection.sessionKey : undefined; + if (delegationCommand) { + await this.handleTelegramDelegationMessage(message, delegationCommand); + return true; + } + switch (target.command) { case "sessions": { await this.api.sendPlainText(message.chat.id, formatSessionList(entries, activeSessionKey)); @@ -1101,6 +1325,23 @@ export class InProcessTunnelRuntime implements TunnelRuntime { return true; } + private async delegationTaskCommandPrefixForMessage(message: TelegramInboundMessage): Promise { + return this.delegationTaskCommandPrefixForConversationId(String(message.chat.id), message.chat.type); + } + + private async delegationTaskCommandPrefixForConversationId(conversationId: string, chatType?: string): Promise { + if (chatType && !isTelegramGroupConversation(chatType) && !isGroupConversationId(conversationId)) return "/task"; + try { + const setup = await this.ensureSetup(); + if (setup?.botUsername) { + return `/task@${normalizeTelegramBotUsername(setup.botUsername)}`; + } + } catch { + // If setup metadata is unavailable, fall back to unscoped task commands. + } + return "/task"; + } + private async handleStart(message: TelegramInboundMessage, nonce: string): Promise { if (!nonce) { await this.api.sendPlainText(message.chat.id, "Missing pairing payload. Re-run /relay connect telegram in Pi and scan the new QR code."); @@ -1921,20 +2162,35 @@ export class InProcessTunnelRuntime implements TunnelRuntime { } private async chatHasActiveBinding(chatId: number): Promise { - return (await this.store.getBindingsByChatId(chatId)).some((binding) => binding.status !== "revoked"); + return this.chatHasActiveBindingFromBindings(await this.store.getBindingsByChatId(chatId)); } private async chatUserHasRevokedBinding(chatId: number, userId: number): Promise { - return (await this.store.getBindingsByChatId(chatId)).some((binding) => binding.status === "revoked" && binding.userId === userId); + return this.chatUserHasRevokedBindingFromBindings(await this.store.getBindingsByChatId(chatId), userId); } private async activeBindingForMessage(chatId: number, userId: number): Promise { - const bindings = (await this.store.getBindingsByChatId(chatId)) - .filter((binding) => binding.status !== "revoked" && binding.userId === userId); - if (bindings.length === 0) return undefined; + return this.activeBindingForMessageFromBindings(await this.store.getBindingsByChatId(chatId), chatId, userId); + } + + private bindingsForChatFromSnapshot(bindings: Record, chatId: number): PersistedBindingRecord[] { + return Object.values(bindings).filter((binding) => binding.chatId === chatId); + } + + private chatHasActiveBindingFromBindings(bindings: readonly PersistedBindingRecord[]): boolean { + return bindings.some((binding) => binding.status !== "revoked"); + } + + private chatUserHasRevokedBindingFromBindings(bindings: readonly PersistedBindingRecord[], userId: number): boolean { + return bindings.some((binding) => binding.status === "revoked" && binding.userId === userId); + } + + private activeBindingForMessageFromBindings(bindings: readonly PersistedBindingRecord[], chatId: number, userId: number): TelegramBindingMetadata | undefined { + const activeBindings = bindings.filter((binding) => binding.status !== "revoked" && binding.userId === userId); + if (activeBindings.length === 0) return undefined; const activeKey = this.activeSessionByChatUser.get(this.activeSessionKey(chatId, userId)); - const active = activeKey ? bindings.find((binding) => binding.sessionKey === activeKey) : undefined; - return active ?? bindings.find((binding) => this.routes.has(binding.sessionKey)) ?? bindings[0]; + const active = activeKey ? activeBindings.find((binding) => binding.sessionKey === activeKey) : undefined; + return active ?? activeBindings.find((binding) => this.routes.has(binding.sessionKey)) ?? activeBindings[0]; } private async sessionEntriesForChat(chatId: number, userId: number): Promise { @@ -2042,7 +2298,7 @@ export class InProcessTunnelRuntime implements TunnelRuntime { }); } - private async sendTextWithKeyboard(chatId: number, text: string, keyboard: ReturnType): Promise { + private async sendTextWithKeyboard(chatId: number, text: string, keyboard: TelegramInlineKeyboard | undefined): Promise { const maybeApi = this.api as TelegramApiClient & { sendPlainTextWithKeyboard?: TelegramApiClient["sendPlainTextWithKeyboard"] }; if (typeof maybeApi.sendPlainTextWithKeyboard === "function") { await maybeApi.sendPlainTextWithKeyboard(chatId, text, keyboard); @@ -2083,6 +2339,7 @@ export class InProcessTunnelRuntime implements TunnelRuntime { this.clearActivityIndicator(route); this.clearProgressState(route); this.clearAnswerStateForRoute(route); + if (await this.finishActiveTelegramDelegationTask(route, status)) return; const binding = await this.activeOutputBindingForRoute(route); try { if (!binding || binding.paused) return; @@ -2141,6 +2398,37 @@ export class InProcessTunnelRuntime implements TunnelRuntime { } } +function telegramDelegationKeyboard(actions: ReturnType["actions"]): ReturnType | undefined { + const buttons = delegationTaskActionButtons(actions); + return buttons ? toTelegramKeyboard(buttons) : undefined; +} + +function delegationCommandFromCallbackAction(kind: DelegationActionKind, taskId: string): DelegationCommand { + switch (kind) { + case "claim": + return { kind, taskId }; + case "approve": + return { kind, taskId }; + case "status": + return { kind, taskId }; + case "decline": + return { kind, taskId }; + case "cancel": + return { kind, taskId }; + } +} + +function telegramMessageFromCallback(callback: TelegramInboundCallback, text: string): TelegramInboundMessage { + return { + kind: "message", + updateId: callback.updateId, + messageId: callback.messageId ?? callback.updateId, + text, + chat: callback.chat, + user: callback.user, + }; +} + function parseTelegramGroupCommandTarget(text: string): TelegramGroupCommandTarget | undefined { const trimmed = text.trim(); const match = trimmed.match(/^\/([A-Za-z0-9_-]+)(?:@([A-Za-z][A-Za-z0-9_]{4,31}))?(?:\s+([\s\S]*))?$/); @@ -2158,6 +2446,11 @@ function isTelegramGroupConversation(type: string): boolean { return type === "group" || type === "supergroup"; } +function isGroupConversationId(conversationId: string): boolean { + const chatId = Number(conversationId); + return Number.isSafeInteger(chatId) && chatId < 0; +} + function normalizePairingApproval(value: PairingApprovalDecision | boolean): PairingApprovalDecision { if (value === true) return "allow"; if (value === false) return "deny"; diff --git a/extensions/relay/broker/process.js b/extensions/relay/broker/process.js index 68d26db..4808b66 100644 --- a/extensions/relay/broker/process.js +++ b/extensions/relay/broker/process.js @@ -479,7 +479,14 @@ function getLiveRoutesForChat(chatId, userId) { async function getActiveLiveRoutesForChat(chatId, userId, state = undefined) { const active = []; - state = state ?? await loadState(); + const snapshot = state ? bindingAuthorityStateFromData(state) : await loadStateSnapshot(); + if (snapshot.kind === 'state-unavailable') { + for (const route of routes.values()) { + if (route.binding?.chatId === chatId && route.binding?.userId === userId) active.push(route); + } + return active; + } + state = snapshot.data; for (const route of routes.values()) { const binding = await activeBindingForRoute(route, { includePaused: true, state }); if (!binding) { @@ -493,7 +500,11 @@ async function getActiveLiveRoutesForChat(chatId, userId, state = undefined) { } async function getPersistedBindingsForChat(chatId, userId, state = undefined) { - state = state ?? await loadState(); + if (!state) { + const snapshot = await loadStateSnapshot(); + if (snapshot.kind === 'state-unavailable') return undefined; + state = snapshot.data; + } return Object.values(state.bindings) .filter((binding) => binding.chatId === chatId && binding.userId === userId && binding.status !== 'revoked'); } @@ -524,7 +535,7 @@ async function stripRevokedBindingFromRoute(route) { async function getSessionEntriesForChat(chatId, userId) { const state = await loadState(); const live = await getActiveLiveRoutesForChat(chatId, userId, state); - const persisted = await getPersistedBindingsForChat(chatId, userId, state); + const persisted = await getPersistedBindingsForChat(chatId, userId, state) ?? []; const seen = new Set(live.map((route) => route.sessionKey)); return [ ...live.map(routeToSessionEntry), @@ -1515,7 +1526,9 @@ async function handleAuthorizedCommand(message, route, command, args) { } if (!route || !binding) { const persisted = await getPersistedBindingsForChat(message.chat.id, message.user.id); - if (persisted.length > 0) { + if (!persisted) { + await sendPlainText(message.chat.id, 'Relay state is temporarily unavailable; retry shortly.'); + } else if (persisted.length > 0) { await sendPlainText(message.chat.id, 'The selected Pi session is currently offline. Resume it locally, then try again.'); } else { await sendPlainText(message.chat.id, 'This chat is not paired to an active Pi session. Run /relay connect telegram locally first.'); @@ -1905,6 +1918,10 @@ async function processCallback(callback) { if (!route) { const persisted = await getPersistedBindingsForChat(callback.chat.id, callback.user.id); + if (!persisted) { + await answerCallbackQuery(callback.callbackQueryId, 'Relay state unavailable.'); + return; + } await answerCallbackQuery(callback.callbackQueryId, persisted.length > 0 ? 'Pi session is offline.' : 'This chat is not paired.'); if (persisted.length > 0) { await sendPlainText(callback.chat.id, 'The selected Pi session is currently offline. Resume it locally, then try again.'); diff --git a/extensions/relay/broker/tunnel-runtime.ts b/extensions/relay/broker/tunnel-runtime.ts index 3564093..bba7659 100644 --- a/extensions/relay/broker/tunnel-runtime.ts +++ b/extensions/relay/broker/tunnel-runtime.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { readFile, unlink, writeFile } from "node:fs/promises"; import { createConnection, type Socket } from "node:net"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -85,11 +85,22 @@ export class BrokerTunnelRuntime implements TunnelRuntime { async stop(): Promise { this.started = false; - if (this.reconnectTimer) clearTimeout(this.reconnectTimer); - this.reconnectTimer = undefined; - this.rejectPending(new Error("Broker runtime stopped.")); - this.socket?.destroy(); - this.socket = undefined; + this.disconnectClient(new Error("Broker runtime stopped.")); + } + + async restartBrokerProcess(): Promise { + this.started = false; + this.disconnectClient(new Error("Broker runtime restarted.")); + const pid = await this.readBrokerPid(); + if (pid !== undefined && this.isProcessAlive(pid)) { + try { + process.kill(pid, "SIGTERM"); + } catch (error) { + if (!isNoSuchProcessError(error)) throw new Error(`Failed to stop PiRelay broker process ${pid}: ${error instanceof Error ? error.message : String(error)}`); + } + await this.waitForBrokerProcessExit(pid); + } + await this.unlinkBrokerFiles(); } async ensureSetup(): Promise { @@ -128,6 +139,14 @@ export class BrokerTunnelRuntime implements TunnelRuntime { return relayRouteStateForRoute(route, { channel: BROKER_CHANNEL }); } + private disconnectClient(error: Error): void { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + this.rejectPending(error); + this.socket?.destroy(); + this.socket = undefined; + } + private async ensureConnected(): Promise { if (this.socket && !this.socket.destroyed) return; if (this.connecting) return this.connecting; @@ -419,6 +438,49 @@ export class BrokerTunnelRuntime implements TunnelRuntime { } } + private async readBrokerPid(): Promise { + try { + const raw = (await readFile(this.pidPath, "utf8")).trim(); + const pid = Number(raw); + if (Number.isInteger(pid) && pid > 0) return pid; + throw new Error(`Invalid broker pid file contents: ${raw || "empty"}`); + } catch (error) { + if (isMissingFileError(error)) return undefined; + throw new Error(`Could not read PiRelay broker pid file: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (isNoSuchProcessError(error)) return false; + throw new Error(`Could not inspect PiRelay broker process ${pid}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async waitForBrokerProcessExit(pid: number): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (!this.isProcessAlive(pid)) return; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); + } + throw new Error(`PiRelay broker process ${pid} did not stop in time.`); + } + + private async unlinkBrokerFiles(): Promise { + await Promise.all([this.unlinkBrokerFile(this.socketPath), this.unlinkBrokerFile(this.pidPath)]); + } + + private async unlinkBrokerFile(path: string): Promise { + try { + await unlink(path); + } catch (error) { + if (!isMissingFileError(error)) throw new Error(`Could not remove PiRelay broker file ${path}: ${error instanceof Error ? error.message : String(error)}`); + } + } + private async spawnBroker(): Promise { const brokerPath = fileURLToPath(new URL("./process.js", import.meta.url)); const child = spawn(process.execPath, [brokerPath], { @@ -478,3 +540,11 @@ export class BrokerTunnelRuntime implements TunnelRuntime { this.reconnectTimer.unref?.(); } } + +function isMissingFileError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT"; +} + +function isNoSuchProcessError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ESRCH"; +} diff --git a/extensions/relay/commands/delegation.ts b/extensions/relay/commands/delegation.ts new file mode 100644 index 0000000..48634b7 --- /dev/null +++ b/extensions/relay/commands/delegation.ts @@ -0,0 +1,291 @@ +import type { DelegationTaskRecord, DelegationTaskStatus, DelegationTaskTarget } from "../core/agent-delegation.js"; +import { safeDelegationText } from "../core/agent-delegation.js"; +import type { ChannelButtonLayout } from "../core/channel-adapter.js"; +import type { MessengerKind } from "../core/messenger-ref.js"; +import { parseRemoteCommandInvocation } from "./remote.js"; + +export type DelegationCommand = + | { kind: "create"; target: DelegationTaskTarget; goal: string; rawGoal: string; awaitApproval: boolean } + | { kind: "claim"; taskId: string } + | { kind: "approve"; taskId: string } + | { kind: "decline"; taskId: string; reason?: string } + | { kind: "cancel"; taskId: string; reason?: string } + | { kind: "status"; taskId: string } + | { kind: "history"; taskId?: string }; + +export type DelegationActionKind = "claim" | "decline" | "cancel" | "status" | "approve"; + +export interface DelegationTaskAction { + kind: DelegationActionKind; + label: string; + command: string; + actionId: string; +} + +export interface DelegationTaskCard { + text: string; + actions: DelegationTaskAction[]; + fallbackText: string; + accessibilityText: string; + presentation: DelegationTaskPresentation; +} + +export interface DelegationTaskPresentationField { + label: string; + value: string; +} + +export interface DelegationTaskPresentation { + title: string; + status: { value: DelegationTaskStatus; label: string; icon: string }; + fields: DelegationTaskPresentationField[]; + latest?: DelegationTaskPresentationField; + actions: DelegationTaskAction[]; + fallbackText: string; + accessibilityText: string; +} + +export interface PlatformDelegationActionSurface { + platform: MessengerKind | (string & {}); + textFallback: string; + actions: DelegationTaskAction[]; +} + +export function parseDelegationInvocation(text: string, options: { prefixes?: string[] } = {}): DelegationCommand | undefined { + const invocation = parseRemoteCommandInvocation(text, { prefixes: options.prefixes ?? ["relay", "pirelay"] }); + if (!invocation) return undefined; + return parseDelegationCommand(invocation.command, invocation.args); +} + +export function parseDelegationCommand(command: string, args: string): DelegationCommand | undefined { + const normalized = command.trim().toLowerCase().replace(/_/g, "-"); + if (normalized === "delegate" || normalized === "propose") return parseCreateCommand(args, normalized === "propose"); + if (normalized === "claim") return parseTaskIdCommand("claim", args); + if (normalized === "approve") return parseTaskIdCommand("approve", args); + if (normalized === "decline") return parseTaskIdWithReasonCommand("decline", args); + if (normalized === "cancel") return parseTaskIdWithReasonCommand("cancel", args); + if (normalized === "task") return parseTaskCommand(args); + if (normalized === "tasks" || normalized === "history") return parseHistoryCommand(args); + return undefined; +} + +export function delegationActionId(kind: DelegationActionKind, taskId: string): string { + return `pirelay:delegation:${kind}:${taskId}`; +} + +export function parseDelegationActionId(value: string): { kind: DelegationActionKind; taskId: string } | undefined { + const [namespace, feature, kind, ...taskIdParts] = value.split(":"); + if (namespace !== "pirelay" || feature !== "delegation" || !isDelegationActionKind(kind)) return undefined; + const taskId = taskIdParts.join(":").trim(); + if (!taskId) return undefined; + return { kind, taskId }; +} + +export function delegationTaskActionsForStatus(task: Pick, options: { commandPrefix?: string } = {}): DelegationTaskAction[] { + const prefix = options.commandPrefix ?? "/task"; + const action = (kind: DelegationActionKind, label: string): DelegationTaskAction => ({ + kind, + label, + command: `${prefix} ${kind} ${task.id}`, + actionId: delegationActionId(kind, task.id), + }); + switch (task.status) { + case "proposed": + case "claimable": + return [action("claim", "Claim"), action("decline", "Decline"), action("cancel", "Cancel"), action("status", "Status")]; + case "awaiting-approval": + return [action("approve", "Approve"), action("cancel", "Cancel"), action("status", "Status")]; + case "claimed": + case "running": + return [action("cancel", "Cancel"), action("status", "Status")]; + default: + return [action("status", "Status")]; + } +} + +export function renderDelegationTaskPresentation(task: DelegationTaskRecord, options: { commandPrefix?: string; includeActions?: boolean; maxTextChars?: number } = {}): DelegationTaskPresentation { + const includeActions = options.includeActions ?? true; + const actions = includeActions ? delegationTaskActionsForStatus(task, { commandPrefix: options.commandPrefix }) : []; + const status = delegationPresentationStatus(task.status); + const source = task.sourceSessionLabel ? `${task.sourceMachineLabel ?? task.sourceMachineId}/${task.sourceSessionLabel}` : task.sourceMachineLabel ?? task.sourceMachineId; + const fields: DelegationTaskPresentationField[] = [ + { label: "Status", value: status.label }, + { label: "From", value: source }, + { label: "Target", value: renderDelegationTargetLabel(task.target) }, + { label: "Goal", value: task.goal }, + task.constraints ? { label: "Constraints", value: task.constraints } : undefined, + { label: "Expires", value: task.expiresAt }, + task.claimedBy ? { label: "Claimed by", value: renderDelegationClaimant(task.claimedBy) } : undefined, + ].filter((field): field is DelegationTaskPresentationField => Boolean(field)); + const latestText = task.lastSafeSummary ?? [...task.audit].reverse().find((event) => event.summary && isVisibleDelegationAuditSummary(event.kind))?.summary; + const latest = latestText ? { label: terminalDelegationStatus(task.status) ? "Result" : "Latest", value: latestText } : undefined; + const fallbackText = actions.length > 0 ? actions.map((action) => action.command).join("\n") : `${options.commandPrefix ?? "/task"} status ${task.id}`; + const title = `${status.icon} Delegation ${task.id}`; + const fieldText = fields.map((field) => `${field.label}: ${field.value}`).join("\n"); + const latestLine = latest ? `\n${latest.label}: ${latest.value}` : ""; + const fallbackBlock = actions.length > 0 ? `\n\nFallback commands:\n${fallbackText}` : ""; + const accessibilityText = `${title}\n${fieldText}${latestLine}${fallbackBlock}`; + const maxTextChars = options.maxTextChars ?? 3900; + const boundedAccessibility = safeDelegationText(accessibilityText, { maxLength: maxTextChars, fallback: `${title}\n${fieldText}`.slice(0, maxTextChars) }); + return { + title, + status, + fields, + latest, + actions, + fallbackText: safeDelegationText(fallbackText, { maxLength: maxTextChars, fallback: fallbackText.slice(0, maxTextChars) }), + accessibilityText: boundedAccessibility, + }; +} + +export function renderDelegationTaskCard(task: DelegationTaskRecord, options: { commandPrefix?: string; includeActions?: boolean; maxTextChars?: number } = {}): DelegationTaskCard { + const presentation = renderDelegationTaskPresentation(task, options); + return { + text: presentation.accessibilityText, + actions: presentation.actions, + fallbackText: presentation.fallbackText, + accessibilityText: presentation.accessibilityText, + presentation, + }; +} + +export function delegationTaskActionButtons(actions: readonly DelegationTaskAction[]): ChannelButtonLayout | undefined { + if (actions.length === 0) return undefined; + return [actions.map((action) => ({ + label: action.label, + actionData: action.actionId, + style: action.kind === "claim" || action.kind === "approve" ? "primary" : action.kind === "cancel" || action.kind === "decline" ? "danger" : "default", + }))]; +} + +function delegationPresentationStatus(status: DelegationTaskStatus): DelegationTaskPresentation["status"] { + switch (status) { + case "proposed": + return { value: status, label: "Proposed", icon: "🧩" }; + case "awaiting-approval": + return { value: status, label: "Awaiting approval", icon: "⏳" }; + case "claimable": + return { value: status, label: "Claimable", icon: "🧩" }; + case "claimed": + return { value: status, label: "Claimed", icon: "📌" }; + case "running": + return { value: status, label: "Running", icon: "🏃" }; + case "completed": + return { value: status, label: "Completed", icon: "✅" }; + case "blocked": + return { value: status, label: "Blocked", icon: "🚧" }; + case "failed": + return { value: status, label: "Failed", icon: "❌" }; + case "declined": + return { value: status, label: "Declined", icon: "↩️" }; + case "cancelled": + return { value: status, label: "Cancelled", icon: "🛑" }; + case "expired": + return { value: status, label: "Expired", icon: "⌛" }; + case "rejected": + return { value: status, label: "Rejected", icon: "⛔" }; + } +} + +function renderDelegationTargetLabel(target: DelegationTaskTarget): string { + return target.displayName ?? (target.kind === "machine" ? target.machineId : `#${target.capability}`); +} + +function renderDelegationClaimant(claimant: DelegationTaskRecord["claimedBy"]): string { + if (!claimant) return "unknown"; + return claimant.sessionLabel ? `${claimant.machineId}/${claimant.sessionLabel}` : claimant.machineId; +} + +function terminalDelegationStatus(status: DelegationTaskStatus): boolean { + return status === "completed" || status === "failed" || status === "blocked" || status === "declined" || status === "cancelled" || status === "expired" || status === "rejected"; +} + +function isVisibleDelegationAuditSummary(kind: string): boolean { + return kind === "blocked" || kind === "completed" || kind === "failed" || kind === "declined" || kind === "cancelled" || kind === "expired" || kind === "rejected" || kind === "running" || kind === "claimed"; +} + +export function platformDelegationActionSurface(platform: MessengerKind | (string & {}), task: DelegationTaskRecord): PlatformDelegationActionSurface { + const prefix = platform === "telegram" ? "/task" : "relay task"; + const actions = delegationTaskActionsForStatus(task, { commandPrefix: prefix }); + const textFallback = actions.length > 0 ? actions.map((action) => action.command).join(" | ") : `${prefix} status ${task.id}`; + return { platform, textFallback, actions }; +} + +function parseCreateCommand(args: string, awaitApproval: boolean): DelegationCommand | undefined { + const parsed = splitFirstToken(args); + if (!parsed || !parsed.rest) return undefined; + const target = parseDelegationTarget(parsed.first); + if (!target) return undefined; + return { kind: "create", target, goal: safeDelegationText(parsed.rest), rawGoal: parsed.rest, awaitApproval }; +} + +function parseTaskCommand(args: string): DelegationCommand | undefined { + const parsed = splitFirstToken(args); + if (!parsed) return { kind: "history" }; + const subcommand = parsed.first.toLowerCase().replace(/_/g, "-"); + if (subcommand === "claim") return parseTaskIdCommand("claim", parsed.rest); + if (subcommand === "approve") return parseTaskIdCommand("approve", parsed.rest); + if (subcommand === "decline") return parseTaskIdWithReasonCommand("decline", parsed.rest); + if (subcommand === "cancel") return parseTaskIdWithReasonCommand("cancel", parsed.rest); + if (subcommand === "status") return parseTaskIdCommand("status", parsed.rest); + if (subcommand === "history" || subcommand === "list") return parseHistoryCommand(parsed.rest); + return parseTaskIdCommand("status", args); +} + +function parseHistoryCommand(args: string): DelegationCommand { + const taskId = args.trim().split(/\s+/)[0]?.trim(); + return taskId ? { kind: "history", taskId } : { kind: "history" }; +} + +function parseTaskIdCommand(kind: "claim" | "approve" | "status", args: string): DelegationCommand | undefined { + const taskId = args.trim().split(/\s+/)[0]?.trim(); + if (!taskId) return undefined; + return { kind, taskId }; +} + +function parseTaskIdWithReasonCommand(kind: "decline" | "cancel", args: string): DelegationCommand | undefined { + const parsed = splitFirstToken(args); + if (!parsed) return undefined; + const reason = parsed.rest ? safeDelegationText(parsed.rest) : undefined; + return { kind, taskId: parsed.first, reason }; +} + +function parseDelegationTarget(value: string): DelegationTaskTarget | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith("#")) { + const capability = normalizeTargetValue(trimmed.slice(1)); + return capability ? { kind: "capability", capability } : undefined; + } + const [prefix, ...rest] = trimmed.split(":"); + if (prefix === "capability" || prefix === "cap") { + const capability = normalizeTargetValue(rest.join(":")); + return capability ? { kind: "capability", capability } : undefined; + } + if (prefix === "machine" || prefix === "bot") { + const machineId = normalizeTargetValue(rest.join(":")); + return machineId ? { kind: "machine", machineId } : undefined; + } + const machineId = normalizeTargetValue(trimmed); + return machineId ? { kind: "machine", machineId } : undefined; +} + +function normalizeTargetValue(value: string): string { + return value.trim().replace(/^@+/, "").replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128); +} + +function splitFirstToken(args: string): { first: string; rest: string } | undefined { + const trimmed = args.trim(); + if (!trimmed) return undefined; + const [first = "", ...rest] = trimmed.split(/\s+/); + if (!first) return undefined; + return { first, rest: rest.join(" ").trim() }; +} + +function isDelegationActionKind(value: string | undefined): value is DelegationActionKind { + return value === "claim" || value === "decline" || value === "cancel" || value === "status" || value === "approve"; +} + +export function isDelegationTaskControlStatus(status: DelegationTaskStatus): boolean { + return status === "proposed" || status === "awaiting-approval" || status === "claimable" || status === "claimed" || status === "running" || status === "blocked"; +} diff --git a/extensions/relay/commands/remote.ts b/extensions/relay/commands/remote.ts index 5e42cc8..f789b6f 100644 --- a/extensions/relay/commands/remote.ts +++ b/extensions/relay/commands/remote.ts @@ -97,6 +97,7 @@ export function buildHelpText(options: { includeBrokerOnly?: boolean; title?: st const sharedRoomLines = options.includeSharedRoomHints === false ? [] : [ "", "Shared-room machine bots: use /use to select a machine session, /to for one-shot prompts, or mention/reply to a machine bot when plain room text is unavailable. In Telegram privacy-mode groups, address the bot explicitly with its username: /sessions@, /use@ , /to@ .", + "Agent delegation (when enabled for a trusted shared room): use /delegate to create a visible task card, then /task@ [task-id] in Telegram groups (or /task [task-id] in private/other clients) for task controls.", ]; return [ options.title ?? "PiRelay commands:", diff --git a/extensions/relay/config/diagnostics.ts b/extensions/relay/config/diagnostics.ts index 1ebc37e..7e37e92 100644 --- a/extensions/relay/config/diagnostics.ts +++ b/extensions/relay/config/diagnostics.ts @@ -55,6 +55,15 @@ export function collectRelayDiagnostics(config: ResolvedRelayConfig): RelayDiagn } } + if (messenger.delegation?.enabled) { + items.push({ level: "ok", message: `${ref}: delegation ${messenger.delegation.autonomy}; trusted peers: ${messenger.delegation.trustedPeers.length}; capabilities: ${messenger.delegation.localCapabilities.length > 0 ? messenger.delegation.localCapabilities.join(", ") : "none"}; approval gates: required for sensitive delegated work.` }); + if (!messenger.sharedRoom.enabled) items.push({ level: "warning", message: `${ref}: delegation is enabled but shared-room mode is not enabled; use propose-only or disable delegation until a room is configured.` }); + if (messenger.delegation.trustedPeers.length === 0) items.push({ level: "warning", message: `${ref}: delegation is enabled without trusted peer bots; bot-authored tasks will be rejected.` }); + if (!messenger.delegation.requireHumanApproval && messenger.delegation.autonomy !== "propose-only") items.push({ level: "warning", message: `${ref}: delegation can auto-claim under ${messenger.delegation.autonomy}; verify trusted peer, room, target, and approval policies.` }); + if (!messenger.botUserId && messenger.ref.kind === "slack") items.push({ level: "warning", message: `${ref}: delegation loop prevention works best with Slack botUserId configured.` }); + if (!messenger.applicationId && messenger.ref.kind === "discord") items.push({ level: "warning", message: `${ref}: delegation command surfaces work best with Discord applicationId/clientId configured.` }); + } + const ownership = resolveMessengerIngressOwnership({ messenger: messenger.ref, localMachineId: config.relay.machineId, diff --git a/extensions/relay/config/loader.ts b/extensions/relay/config/loader.ts index 7c78462..8d267a9 100644 --- a/extensions/relay/config/loader.ts +++ b/extensions/relay/config/loader.ts @@ -6,10 +6,11 @@ import { canonicalizeRelayConfigFile, hasLegacyRelayConfigKeys } from "./legacy. import { DEFAULT_MESSENGER_INSTANCE_ID, isValidMessengerInstanceId, isValidMessengerKind } from "../core/messenger-ref.js"; import type { MessengerKind, MessengerRef } from "../core/messenger-ref.js"; import type { MessengerIngressPolicy } from "../broker/protocol.js"; -import type { MessengerInstanceFileConfig, RelayConfigFile, RelayConfigLoadOptions, RelayDefaultsConfig, RelayMachineConfig, ResolvedMessengerInstanceConfig, ResolvedRelayConfig } from "./schema.js"; +import type { MessengerInstanceFileConfig, RelayAgentDelegationConfig, RelayConfigFile, RelayConfigLoadOptions, RelayDefaultsConfig, RelayMachineConfig, ResolvedMessengerInstanceConfig, ResolvedRelayAgentDelegationConfig, ResolvedRelayConfig } from "./schema.js"; import { RelayConfigError } from "./schema.js"; const defaultSupportedMessengers = ["telegram", "discord", "slack"] as const; +const delegationAutonomyLevels = new Set(["off", "propose-only", "auto-claim-targeted", "auto-claim-safe-capability"]); const defaultDefaults: RelayDefaultsConfig = { pairingExpiryMs: 5 * 60_000, @@ -88,6 +89,12 @@ function relayMachineAliases(fileConfig: RelayConfigFile, env: NodeJS.ProcessEnv return [...new Set([...fileAliases, ...envAliases].map((alias) => alias.trim()).filter(Boolean))]; } +function relayMachineCapabilities(fileConfig: RelayConfigFile, env: NodeJS.ProcessEnv): string[] { + const fileCapabilities = Array.isArray(fileConfig.relay?.capabilities) ? fileConfig.relay.capabilities : []; + const envCapabilities = parseStringList(env.PI_RELAY_MACHINE_CAPABILITIES) ?? []; + return [...new Set([...fileCapabilities, ...envCapabilities].map((capability) => capability.trim()).filter(Boolean))]; +} + function resolveSecret(env: NodeJS.ProcessEnv, value: string | undefined, envName: string | undefined): string | undefined { if (value) return value; if (envName) return env[envName]; @@ -108,6 +115,33 @@ function ensureValidDefaults(defaults: RelayDefaultsConfig): void { if (defaults.allowedImageMimeTypes.length === 0) throw new RelayConfigError("defaults.allowedImageMimeTypes must include at least one MIME type."); } +function resolveDelegationConfig(config: RelayAgentDelegationConfig | undefined, relay: RelayMachineConfig, messengerLabel: string): ResolvedRelayAgentDelegationConfig { + const autonomy = config?.autonomy ?? (config?.enabled ? "propose-only" : "off"); + if (!delegationAutonomyLevels.has(autonomy)) throw new RelayConfigError(`${messengerLabel} delegation.autonomy must be off, propose-only, auto-claim-targeted, or auto-claim-safe-capability.`); + const taskExpiryMs = config?.taskExpiryMs ?? 10 * 60_000; + const runningTimeoutMs = config?.runningTimeoutMs ?? 60 * 60_000; + const maxDepth = config?.maxDepth ?? 1; + const maxVisibleSummaryChars = config?.maxVisibleSummaryChars ?? 320; + const maxHistory = config?.maxHistory ?? 50; + if (taskExpiryMs < 30_000) throw new RelayConfigError(`${messengerLabel} delegation.taskExpiryMs must be at least 30000.`); + if (runningTimeoutMs < 60_000) throw new RelayConfigError(`${messengerLabel} delegation.runningTimeoutMs must be at least 60000.`); + if (maxDepth < 0) throw new RelayConfigError(`${messengerLabel} delegation.maxDepth must not be negative.`); + if (maxVisibleSummaryChars < 80) throw new RelayConfigError(`${messengerLabel} delegation.maxVisibleSummaryChars must be at least 80.`); + if (maxHistory < 1) throw new RelayConfigError(`${messengerLabel} delegation.maxHistory must be positive.`); + return { + enabled: config?.enabled === true, + autonomy, + trustedPeers: config?.trustedPeers ?? [], + localCapabilities: [...new Set([...(relay.capabilities ?? []), ...(config?.localCapabilities ?? [])].map((capability) => capability.trim()).filter(Boolean))], + taskExpiryMs, + runningTimeoutMs, + maxDepth, + maxVisibleSummaryChars, + maxHistory, + requireHumanApproval: config?.requireHumanApproval ?? autonomy === "propose-only", + }; +} + function resolveMessengerInstance(input: { ref: MessengerRef; config: MessengerInstanceFileConfig; @@ -157,6 +191,7 @@ function resolveMessengerInstance(input: { allowGuildIds: config.allowGuildIds ?? [], allowChannelMessages: config.allowChannelMessages, sharedRoom: config.sharedRoom ?? {}, + delegation: resolveDelegationConfig(config.delegation, relay, `${ref.kind}:${ref.instanceId}`), ingressPolicy: normalizePolicy(config, relay), ownerMachineId: config.ownerMachineId, brokerGroup: config.brokerGroup ?? relay.brokerGroup, @@ -213,6 +248,7 @@ export async function loadRelayConfig(options: RelayConfigLoadOptions = {}): Pro stateDir: expandHome(fileConfig.relay?.stateDir ?? env.PI_RELAY_STATE_DIR ?? env.PI_TELEGRAM_TUNNEL_STATE_DIR ?? fileConfig.stateDir ?? DEFAULT_PIRELAY_STATE_DIR), displayName: fileConfig.relay?.displayName ?? env.PI_RELAY_MACHINE_DISPLAY_NAME, aliases: relayMachineAliases(fileConfig, env), + capabilities: relayMachineCapabilities(fileConfig, env), brokerNamespace: fileConfig.relay?.brokerNamespace ?? env.PI_RELAY_BROKER_NAMESPACE, brokerGroup: fileConfig.relay?.brokerGroup ?? env.PI_RELAY_BROKER_GROUP, brokerPeers: fileConfig.relay?.brokerPeers ?? [], diff --git a/extensions/relay/config/schema.ts b/extensions/relay/config/schema.ts index 7c51684..3eae2f5 100644 --- a/extensions/relay/config/schema.ts +++ b/extensions/relay/config/schema.ts @@ -1,4 +1,5 @@ import type { BrokerPeerConfig, MessengerIngressPolicy } from "../broker/protocol.js"; +import type { DelegationAutonomyLevel, TrustedDelegationPeer } from "../core/agent-delegation.js"; import type { MessengerKind, MessengerRef } from "../core/messenger-ref.js"; export interface RelayDefaultsConfig { @@ -15,11 +16,38 @@ export interface RelayMachineConfig { stateDir: string; displayName?: string; aliases: string[]; + capabilities?: string[]; brokerNamespace?: string; brokerGroup?: string; brokerPeers: BrokerPeerConfig[]; } +export interface RelayAgentDelegationConfig { + enabled?: boolean; + autonomy?: DelegationAutonomyLevel; + trustedPeers?: TrustedDelegationPeer[]; + localCapabilities?: string[]; + taskExpiryMs?: number; + runningTimeoutMs?: number; + maxDepth?: number; + maxVisibleSummaryChars?: number; + maxHistory?: number; + requireHumanApproval?: boolean; +} + +export interface ResolvedRelayAgentDelegationConfig { + enabled: boolean; + autonomy: DelegationAutonomyLevel; + trustedPeers: TrustedDelegationPeer[]; + localCapabilities: string[]; + taskExpiryMs: number; + runningTimeoutMs: number; + maxDepth: number; + maxVisibleSummaryChars: number; + maxHistory: number; + requireHumanApproval: boolean; +} + export interface MessengerSharedRoomConfig { enabled?: boolean; roomHint?: string; @@ -54,6 +82,7 @@ export interface MessengerInstanceFileConfig { allowGuildIds?: string[]; allowChannelMessages?: boolean; sharedRoom?: MessengerSharedRoomConfig; + delegation?: RelayAgentDelegationConfig; ingressPolicy?: MessengerIngressPolicy; ownerMachineId?: string; brokerGroup?: string; @@ -123,6 +152,7 @@ export interface ResolvedMessengerInstanceConfig { allowGuildIds: string[]; allowChannelMessages?: boolean; sharedRoom: MessengerSharedRoomConfig; + delegation?: ResolvedRelayAgentDelegationConfig; ingressPolicy: MessengerIngressPolicy; ownerMachineId?: string; brokerGroup?: string; diff --git a/extensions/relay/config/setup.ts b/extensions/relay/config/setup.ts index c08fb93..b607dcb 100644 --- a/extensions/relay/config/setup.ts +++ b/extensions/relay/config/setup.ts @@ -19,7 +19,7 @@ export interface RelaySetupFacts { } export interface RelayLocalCommandIntent { - subcommand?: "setup" | "connect" | "send-file" | "disconnect" | "status" | "doctor" | "trusted" | "untrust"; + subcommand?: "setup" | "connect" | "send-file" | "restart" | "disconnect" | "status" | "doctor" | "trusted" | "untrust"; channel?: RelaySetupChannel; messengerRef?: string; sendFileTarget?: string; @@ -49,7 +49,7 @@ export function completeRelayLocalCommand(prefix: string, options: { compatibili const parts = prefix.trim().split(/\s+/).filter(Boolean); const subcommands = options.compatibilityCommand ? ["setup", "connect", "disconnect", "status"] - : ["setup", "connect", "send-file", "doctor", "disconnect", "status", "trusted", "untrust"]; + : ["setup", "connect", "send-file", "restart", "doctor", "disconnect", "status", "trusted", "untrust"]; if (parts.length === 0) return subcommands; if (parts.length === 1 && !endsWithSpace) { @@ -94,7 +94,7 @@ export function parseRelayLocalCommand(args: string, options: { compatibilityCom args: rest.slice(1).join(" "), }; } - if (subcommand === "doctor" || subcommand === "disconnect" || subcommand === "status" || subcommand === "trusted" || subcommand === "untrust") { + if (subcommand === "doctor" || subcommand === "restart" || subcommand === "disconnect" || subcommand === "status" || subcommand === "trusted" || subcommand === "untrust") { return { subcommand, args: rest.join(" ") }; } @@ -449,7 +449,7 @@ function channelStatus(config: TelegramTunnelConfig, channel: RelaySetupChannel) } function normalizeSubcommand(value: string | undefined): RelayLocalCommandIntent["subcommand"] | undefined { - if (value === "setup" || value === "connect" || value === "send-file" || value === "disconnect" || value === "status" || value === "doctor" || value === "trusted" || value === "untrust") return value; + if (value === "setup" || value === "connect" || value === "send-file" || value === "restart" || value === "disconnect" || value === "status" || value === "doctor" || value === "trusted" || value === "untrust") return value; return undefined; } diff --git a/extensions/relay/config/tunnel-config.ts b/extensions/relay/config/tunnel-config.ts index 156ad69..53c32cf 100644 --- a/extensions/relay/config/tunnel-config.ts +++ b/extensions/relay/config/tunnel-config.ts @@ -3,7 +3,7 @@ import { constants } from "node:fs"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { DEFAULT_STATE_DIR, getDefaultConfigPath } from "../state/paths.js"; -import type { ConfigLoadResult, DiscordRelayConfig, SlackRelayConfig, TelegramTunnelConfig } from "../core/types.js"; +import type { AgentDelegationRelayConfig, ConfigLoadResult, DiscordRelayConfig, SlackRelayConfig, TelegramTunnelConfig } from "../core/types.js"; import type { MessengerInstanceFileConfig, RelayConfigFile } from "./schema.js"; import { DEFAULT_MAX_PROGRESS_MESSAGE_CHARS, DEFAULT_PROGRESS_INTERVAL_MS, DEFAULT_PROGRESS_MODE, DEFAULT_RECENT_ACTIVITY_LIMIT, DEFAULT_VERBOSE_PROGRESS_INTERVAL_MS, normalizeProgressMode } from "../notifications/progress.js"; import { getDefaultRedactionPatterns } from "../core/utils.js"; @@ -116,6 +116,20 @@ function parseBoolean(value: string | undefined, fallback: boolean | undefined): return fallback; } +function validateDelegationConfig(config: AgentDelegationRelayConfig | undefined, label: string): AgentDelegationRelayConfig | undefined { + if (!config) return undefined; + const autonomy = config.autonomy ?? (config.enabled ? "propose-only" : "off"); + if (autonomy !== "off" && autonomy !== "propose-only" && autonomy !== "auto-claim-targeted" && autonomy !== "auto-claim-safe-capability") { + throw new ConfigError(`${label}.delegation.autonomy must be off, propose-only, auto-claim-targeted, or auto-claim-safe-capability.`); + } + if (config.taskExpiryMs !== undefined && config.taskExpiryMs < 30_000) throw new ConfigError(`${label}.delegation.taskExpiryMs must be at least 30000.`); + if (config.runningTimeoutMs !== undefined && config.runningTimeoutMs < 60_000) throw new ConfigError(`${label}.delegation.runningTimeoutMs must be at least 60000.`); + if (config.maxDepth !== undefined && config.maxDepth < 0) throw new ConfigError(`${label}.delegation.maxDepth must not be negative.`); + if (config.maxVisibleSummaryChars !== undefined && config.maxVisibleSummaryChars < 80) throw new ConfigError(`${label}.delegation.maxVisibleSummaryChars must be at least 80.`); + if (config.maxHistory !== undefined && config.maxHistory < 1) throw new ConfigError(`${label}.delegation.maxHistory must be positive.`); + return { ...config, autonomy }; +} + async function readConfigFile(configPath: string): Promise { try { await access(configPath, constants.R_OK); @@ -153,6 +167,7 @@ function resolveDiscordConfigForInstance(fileConfig: ConfigFileShape | undefined ), allowGuildIds: parseStringList((useLegacyFallback ? process.env.PI_RELAY_DISCORD_ALLOW_GUILD_IDS : undefined) ?? (useLegacyFallback ? fileConfig?.PI_RELAY_DISCORD_ALLOW_GUILD_IDS : undefined)) ?? discordConfig?.allowGuildIds ?? (useLegacyFallback ? legacyConfig?.allowGuildIds : undefined) ?? [], sharedRoom: discordConfig?.sharedRoom ?? (useLegacyFallback ? legacyConfig?.sharedRoom : undefined), + delegation: validateDelegationConfig(discordConfig?.delegation ?? (useLegacyFallback ? legacyConfig?.delegation : undefined), `discord:${instanceId}`), maxTextChars: parseNumber((useLegacyFallback ? process.env.PI_RELAY_DISCORD_MAX_TEXT_CHARS : undefined) ?? (useLegacyFallback ? fileConfig?.PI_RELAY_DISCORD_MAX_TEXT_CHARS : undefined), discordConfig?.limits?.maxTextChars ?? discordConfig?.maxTextChars ?? (useLegacyFallback ? legacyConfig?.maxTextChars : undefined) ?? 2_000), maxFileBytes: parseNumber((useLegacyFallback ? process.env.PI_RELAY_DISCORD_MAX_FILE_BYTES : undefined) ?? (useLegacyFallback ? fileConfig?.PI_RELAY_DISCORD_MAX_FILE_BYTES : undefined), discordConfig?.limits?.maxFileBytes ?? discordConfig?.maxFileBytes ?? (useLegacyFallback ? legacyConfig?.maxFileBytes : undefined) ?? 8 * 1024 * 1024), allowedImageMimeTypes: parseStringList((useLegacyFallback ? process.env.PI_RELAY_DISCORD_ALLOWED_IMAGE_MIME_TYPES : undefined) ?? (useLegacyFallback ? fileConfig?.PI_RELAY_DISCORD_ALLOWED_IMAGE_MIME_TYPES : undefined)) ?? discordConfig?.limits?.allowedImageMimeTypes ?? discordConfig?.allowedImageMimeTypes ?? (useLegacyFallback ? legacyConfig?.allowedImageMimeTypes : undefined) ?? defaultImageMimeTypes, @@ -215,6 +230,7 @@ function resolveSlackConfigForInstance(fileConfig: ConfigFileShape | undefined, slackConfig?.allowChannelMessages ?? (useLegacyFallback ? legacyConfig?.allowChannelMessages : undefined) ?? false, ), sharedRoom: slackConfig?.sharedRoom ?? (useLegacyFallback ? legacyConfig?.sharedRoom : undefined), + delegation: validateDelegationConfig(slackConfig?.delegation ?? (useLegacyFallback ? legacyConfig?.delegation : undefined), `slack:${instanceId}`), maxTextChars: parseNumber((useLegacyFallback ? process.env.PI_RELAY_SLACK_MAX_TEXT_CHARS : undefined) ?? (useLegacyFallback ? fileConfig?.PI_RELAY_SLACK_MAX_TEXT_CHARS : undefined), slackConfig?.limits?.maxTextChars ?? slackConfig?.maxTextChars ?? (useLegacyFallback ? legacyConfig?.maxTextChars : undefined) ?? 3_000), maxFileBytes: parseNumber((useLegacyFallback ? process.env.PI_RELAY_SLACK_MAX_FILE_BYTES : undefined) ?? (useLegacyFallback ? fileConfig?.PI_RELAY_SLACK_MAX_FILE_BYTES : undefined), slackConfig?.limits?.maxFileBytes ?? slackConfig?.maxFileBytes ?? (useLegacyFallback ? legacyConfig?.maxFileBytes : undefined) ?? 10 * 1024 * 1024), allowedImageMimeTypes: parseStringList((useLegacyFallback ? process.env.PI_RELAY_SLACK_ALLOWED_IMAGE_MIME_TYPES : undefined) ?? (useLegacyFallback ? fileConfig?.PI_RELAY_SLACK_ALLOWED_IMAGE_MIME_TYPES : undefined)) ?? slackConfig?.limits?.allowedImageMimeTypes ?? slackConfig?.allowedImageMimeTypes ?? (useLegacyFallback ? legacyConfig?.allowedImageMimeTypes : undefined) ?? defaultImageMimeTypes, @@ -282,6 +298,7 @@ export async function loadTelegramTunnelConfig(): Promise { const machineDisplayName = fileConfig?.relay?.displayName ?? process.env.PI_RELAY_MACHINE_DISPLAY_NAME; const brokerNamespace = fileConfig?.relay?.brokerNamespace ?? process.env.PI_RELAY_BROKER_NAMESPACE; const machineAliases = [...new Set([...(fileConfig?.relay?.aliases ?? []), ...(parseStringList(process.env.PI_RELAY_MACHINE_ALIASES) ?? [])].map((alias) => alias.trim()).filter(Boolean))]; + const machineCapabilities = [...new Set([...(fileConfig?.relay?.capabilities ?? []), ...(parseStringList(process.env.PI_RELAY_MACHINE_CAPABILITIES) ?? [])].map((capability) => capability.trim()).filter(Boolean))]; const busyDeliveryMode = (process.env.PI_TELEGRAM_TUNNEL_BUSY_MODE || fileConfig?.defaults?.busyDeliveryMode || fileConfig?.busyDeliveryMode || "followUp") as | "followUp" | "steer"; @@ -390,6 +407,8 @@ export async function loadTelegramTunnelConfig(): Promise { machineId, machineDisplayName, machineAliases, + machineCapabilities, + delegation: validateDelegationConfig(telegramConfig?.delegation, "telegram:default"), brokerNamespace, pairingExpiryMs, busyDeliveryMode, diff --git a/extensions/relay/core/agent-delegation-approval.ts b/extensions/relay/core/agent-delegation-approval.ts new file mode 100644 index 0000000..535c298 --- /dev/null +++ b/extensions/relay/core/agent-delegation-approval.ts @@ -0,0 +1,118 @@ +import { safeDelegationText, safeIdentityText } from "./agent-delegation.js"; + +export type DelegationApprovalGrantScope = "once" | "task" | "session" | "persistent"; + +export interface DelegationApprovalContext { + taskId?: string; + sessionKey: string; + requesterKey?: string; + bindingKey?: string; + matcherFingerprint: string; + toolName?: string; + category?: string; + expiresAt?: string; +} + +export interface DelegationApprovalGrant { + id: string; + scope: Exclude; + taskId?: string; + sessionKey: string; + requesterKey?: string; + bindingKey?: string; + matcherFingerprint: string; + toolName?: string; + category?: string; + createdAt: string; + expiresAt?: string; + revokedAt?: string; +} + +export interface DelegationApprovalDecisionOption { + id: "approve-once" | "approve-for-task" | "approve-for-session" | "approve-persistent" | "deny"; + label: string; + grantScope?: DelegationApprovalGrantScope; + dangerous?: boolean; +} + +export function delegationApprovalContext(input: DelegationApprovalContext): DelegationApprovalContext { + return { + taskId: input.taskId ? safeIdentityText(input.taskId, undefined) : undefined, + sessionKey: safeIdentityText(input.sessionKey), + requesterKey: input.requesterKey ? safeIdentityText(input.requesterKey, undefined) : undefined, + bindingKey: input.bindingKey ? safeIdentityText(input.bindingKey, undefined) : undefined, + matcherFingerprint: safeIdentityText(input.matcherFingerprint), + toolName: input.toolName ? safeDelegationText(input.toolName, { maxLength: 80 }) : undefined, + category: input.category ? safeDelegationText(input.category, { maxLength: 80 }) : undefined, + expiresAt: input.expiresAt, + }; +} + +export function createDelegationApprovalGrant(input: DelegationApprovalContext & { scope: Exclude; now?: string }): DelegationApprovalGrant { + const context = delegationApprovalContext(input); + const createdAt = input.now ?? new Date().toISOString(); + if (input.scope === "task" && !context.taskId) throw new Error("Task-scoped approval grants require a task id."); + return { + id: approvalGrantId(input.scope, context, createdAt), + scope: input.scope, + taskId: input.scope === "task" ? context.taskId : undefined, + sessionKey: context.sessionKey, + requesterKey: context.requesterKey, + bindingKey: context.bindingKey, + matcherFingerprint: context.matcherFingerprint, + toolName: context.toolName, + category: context.category, + createdAt, + expiresAt: context.expiresAt, + }; +} + +export function delegationApprovalGrantMatches(grant: DelegationApprovalGrant, operation: DelegationApprovalContext, now: Date | string | number = Date.now()): boolean { + if (grant.revokedAt) return false; + if (grant.expiresAt && Date.parse(grant.expiresAt) <= toMillis(now)) return false; + const context = delegationApprovalContext(operation); + if (grant.matcherFingerprint !== context.matcherFingerprint) return false; + if (grant.sessionKey !== context.sessionKey) return false; + if (grant.requesterKey && grant.requesterKey !== context.requesterKey) return false; + if (grant.bindingKey && grant.bindingKey !== context.bindingKey) return false; + if (grant.scope === "task" && (!grant.taskId || grant.taskId !== context.taskId)) return false; + if (grant.scope === "session") return true; + if (grant.scope === "persistent") return true; + return grant.scope === "task"; +} + +export function delegationApprovalOptions(input: { taskId?: string; allowSessionGrant?: boolean; allowPersistentGrant?: boolean } = {}): DelegationApprovalDecisionOption[] { + const options: DelegationApprovalDecisionOption[] = [ + { id: "approve-once", label: "Approve once", grantScope: "once" }, + ]; + if (input.taskId) options.push({ id: "approve-for-task", label: "Approve for this delegated task", grantScope: "task" }); + if (input.allowSessionGrant) options.push({ id: "approve-for-session", label: "Approve matching operations for this session", grantScope: "session" }); + if (input.allowPersistentGrant) options.push({ id: "approve-persistent", label: "Approve matching operations persistently", grantScope: "persistent", dangerous: true }); + options.push({ id: "deny", label: "Deny", dangerous: true }); + return options; +} + +export function formatDelegationApprovalSummary(input: DelegationApprovalContext): string { + const context = delegationApprovalContext(input); + return [ + context.taskId ? `Delegated task: ${context.taskId}` : undefined, + `Session: ${context.sessionKey}`, + context.toolName ? `Tool: ${context.toolName}` : undefined, + context.category ? `Category: ${context.category}` : undefined, + `Matcher: ${context.matcherFingerprint}`, + context.expiresAt ? `Expires: ${context.expiresAt}` : undefined, + ].filter((line): line is string => Boolean(line)).join("\n"); +} + +function approvalGrantId(scope: DelegationApprovalGrant["scope"], context: DelegationApprovalContext, createdAt: string): string { + return ["grant", scope, context.taskId, context.sessionKey, context.requesterKey, context.bindingKey, context.matcherFingerprint, Date.parse(createdAt).toString(36)] + .filter((value): value is string => Boolean(value)) + .map((value) => safeIdentityText(value, "x")) + .join(":"); +} + +function toMillis(value: Date | string | number): number { + if (typeof value === "number") return value; + if (value instanceof Date) return value.getTime(); + return Date.parse(value); +} diff --git a/extensions/relay/core/agent-delegation-runtime.ts b/extensions/relay/core/agent-delegation-runtime.ts new file mode 100644 index 0000000..75b9faa --- /dev/null +++ b/extensions/relay/core/agent-delegation-runtime.ts @@ -0,0 +1,254 @@ +import type { ChannelIdentity, ChannelInboundAction, ChannelInboundMessage } from "./channel-adapter.js"; +import type { AgentDelegationRelayConfig, SessionRoute } from "./types.js"; +import type { DelegationCommand } from "../commands/delegation.js"; +import { parseDelegationActionId } from "../commands/delegation.js"; +import { + createDelegationTask, + evaluateDelegationEligibility, + isTrustedDelegationPeer, + renderDelegationTaskSummary, + safeDelegationText, + transitionDelegationTask, + type DelegationActorRef, + type DelegationAutonomyLevel, + type DelegationTaskRecord, + type DelegationTaskRoomRef, + type TrustedDelegationPeer, +} from "./agent-delegation.js"; + +export interface ResolvedDelegationRuntimePolicy { + enabled: boolean; + autonomy: DelegationAutonomyLevel; + trustedPeers: TrustedDelegationPeer[]; + localCapabilities: string[]; + taskExpiryMs: number; + runningTimeoutMs: number; + maxDepth: number; + maxVisibleSummaryChars: number; + maxHistory: number; + requireHumanApproval: boolean; +} + +export type DelegationIngressDecision = + | { kind: "ignore"; reason: "disabled" | "not-delegation" | "self-authored" | "untrusted-peer" | "not-eligible" | "duplicate"; message?: string } + | { kind: "reject"; message: string } + | { kind: "render-task"; task: DelegationTaskRecord; text: string } + | { kind: "status"; task: DelegationTaskRecord; text: string } + | { kind: "history"; tasks: DelegationTaskRecord[]; text: string } + | { kind: "claim"; task: DelegationTaskRecord; requiresHuman: boolean; prompt: string } + | { kind: "approve" | "cancel" | "decline"; task: DelegationTaskRecord; text: string }; + +export interface DelegationTaskLookup { + get(taskId: string): Promise; + list(options: { room?: DelegationTaskRoomRef; roomConversationId?: string; limit?: number }): Promise; +} + +export interface DelegationIngressInput { + command: DelegationCommand | undefined; + message: ChannelInboundMessage; + policy?: AgentDelegationRelayConfig; + room: DelegationTaskRoomRef; + localMachineId: string; + localMachineLabel?: string; + localBotUserId?: string; + isAuthorizedHuman: boolean; + lookup?: DelegationTaskLookup; + eventAlreadyHandled?: boolean; + eligibleRoutes?: readonly SessionRoute[]; + requireCapabilityCreateSourceScope?: boolean; + createSourceScoped?: boolean; + now?: string; +} + +export function resolveDelegationRuntimePolicy(policy: AgentDelegationRelayConfig | undefined, machineCapabilities: readonly string[] = []): ResolvedDelegationRuntimePolicy { + const requestedAutonomy = policy?.autonomy ?? (policy?.enabled === true ? "propose-only" : "off"); + const autonomy = isDelegationAutonomyLevel(requestedAutonomy) ? requestedAutonomy : "off"; + const enabled = policy?.enabled === true && autonomy !== "off"; + return { + enabled, + autonomy, + trustedPeers: policy?.trustedPeers ?? [], + localCapabilities: [...new Set([...machineCapabilities, ...(policy?.localCapabilities ?? [])].map((capability) => capability.trim()).filter(Boolean))], + taskExpiryMs: policy?.taskExpiryMs ?? 10 * 60_000, + runningTimeoutMs: policy?.runningTimeoutMs ?? 60 * 60_000, + maxDepth: policy?.maxDepth ?? 1, + maxVisibleSummaryChars: policy?.maxVisibleSummaryChars ?? 320, + maxHistory: policy?.maxHistory ?? 50, + requireHumanApproval: policy?.requireHumanApproval ?? autonomy === "propose-only", + }; +} + +export async function evaluateDelegationIngress(input: DelegationIngressInput): Promise { + const policy = resolveDelegationRuntimePolicy(input.policy, []); + if (!input.command) return { kind: "ignore", reason: "not-delegation" }; + if (!policy.enabled || policy.autonomy === "off") return { kind: "ignore", reason: "disabled" }; + if (isSelfAuthoredDelegationEvent(input.message.sender, input.localBotUserId)) return { kind: "ignore", reason: "self-authored" }; + if (input.eventAlreadyHandled) return { kind: "ignore", reason: "duplicate" }; + + const actor = delegationActorFromIdentity(input.message.sender); + const peerBot = isPeerBotIdentity(input.message.sender); + + if (input.command.kind === "approve" && !input.isAuthorizedHuman) { + return { kind: "reject", message: "Only an authorized human may approve a delegation task." }; + } + + if (peerBot) { + const trust = isTrustedDelegationPeer({ + peerId: input.message.sender.userId, + room: input.room, + action: input.command.kind === "create" ? "create" : input.command.kind === "claim" ? "claim" : "control", + target: input.command.kind === "create" ? input.command.target : undefined, + trustedPeers: policy.trustedPeers, + }); + if (!trust.trusted) return { kind: "ignore", reason: "untrusted-peer", message: `Ignored untrusted delegation peer: ${trust.reason}.` }; + } else if (!input.isAuthorizedHuman) { + return { kind: "reject", message: "This identity is not authorized to control delegation tasks." }; + } + + if (input.command.kind === "create") { + if (input.command.target.kind === "capability" && input.requireCapabilityCreateSourceScope && input.createSourceScoped !== true) { + return { kind: "ignore", reason: "not-eligible", message: "Capability delegation creation must be scoped to the local source broker." }; + } + const status = input.command.awaitApproval || policy.requireHumanApproval && peerBot ? "awaiting-approval" : "claimable"; + const task = createDelegationTask({ + sourceMachineId: peerBot ? input.message.sender.userId : input.localMachineId, + sourceMachineLabel: peerBot ? input.message.sender.displayName ?? input.message.sender.username : input.localMachineLabel, + sourceSessionLabel: peerBot ? undefined : "shared-room", + target: input.command.target, + goal: input.command.rawGoal, + room: input.room, + expiryMs: policy.taskExpiryMs, + createdAt: input.now, + status, + visibleTextLimit: policy.maxVisibleSummaryChars, + }); + return { kind: "render-task", task, text: renderDelegationTaskSummary(task) }; + } + + const lookup = input.lookup; + if (!lookup) return { kind: "reject", message: "Delegation task state is unavailable." }; + + if (input.command.kind === "history") { + const tasks = await lookup.list({ room: input.room, limit: policy.maxHistory }); + return { kind: "history", tasks, text: renderDelegationHistory(tasks) }; + } + + const task = await lookup.get(input.command.taskId); + if (!task) return { kind: "reject", message: `Delegation task ${input.command.taskId} was not found or is stale.` }; + if (!delegationTaskRoomMatches(task, input.room)) return { kind: "reject", message: `Delegation task ${input.command.taskId} is not visible in this room or thread.` }; + + if (input.command.kind === "status") return { kind: "status", task, text: renderDelegationTaskSummary(task) }; + + if (input.command.kind === "approve") { + const result = transitionDelegationTask(task, { kind: "approve", actor }, input.now); + if (!result.ok) return { kind: "reject", message: result.message }; + return { kind: "approve", task: result.task, text: renderDelegationTaskSummary(result.task) }; + } + + if (input.command.kind === "cancel") { + const result = transitionDelegationTask(task, { kind: "cancel", actor, reason: input.command.reason }, input.now); + if (!result.ok) return { kind: "reject", message: result.message }; + return { kind: "cancel", task: result.task, text: renderDelegationTaskSummary(result.task) }; + } + + if (input.command.kind === "decline") { + const result = transitionDelegationTask(task, { kind: "decline", actor, reason: input.command.reason }, input.now); + if (!result.ok) return { kind: "reject", message: result.message }; + return { kind: "decline", task: result.task, text: renderDelegationTaskSummary(result.task) }; + } + + const eligible = evaluateDelegationEligibility({ + task, + localMachineId: input.localMachineId, + localCapabilities: policy.localCapabilities, + eligibleSessionKeys: input.eligibleRoutes?.map((route) => route.sessionKey), + maxDepth: policy.maxDepth, + autonomy: policy.autonomy, + }, input.now); + if (!eligible.eligible) return { kind: "ignore", reason: "not-eligible", message: `Delegation task ${task.id} is not eligible locally: ${eligible.reason}.` }; + const route = input.eligibleRoutes?.[0]; + const requiresHuman = eligible.requiresHuman && peerBot; + if (requiresHuman) return { kind: "claim", task, requiresHuman: true, prompt: buildDelegatedTaskPrompt(task) }; + const claimant = { machineId: input.localMachineId, sessionKey: route?.sessionKey, sessionLabel: route?.sessionLabel, botId: input.localBotUserId }; + const result = transitionDelegationTask(task, { kind: "claim", actor, claimant }, input.now); + if (!result.ok) return { kind: "reject", message: result.message }; + return { kind: "claim", task: result.task, requiresHuman: false, prompt: buildDelegatedTaskPrompt(result.task) }; +} + +export function delegationRoomFromMessage(message: ChannelInboundMessage | ChannelInboundAction, instanceId: string): DelegationTaskRoomRef { + return { + messenger: message.channel, + instanceId, + conversationId: message.conversation.id, + threadId: typeof message.metadata?.threadId === "string" ? message.metadata.threadId : typeof message.metadata?.threadTs === "string" ? message.metadata.threadTs : undefined, + messageId: "messageId" in message ? message.messageId : undefined, + }; +} + +export function delegationIngressEventKey(input: { message: ChannelInboundMessage; room: DelegationTaskRoomRef; command: DelegationCommand }): string { + const taskId = "taskId" in input.command ? input.command.taskId : undefined; + return [ + input.room.messenger, + input.room.instanceId, + input.room.conversationId, + input.room.threadId ?? "root", + input.message.updateId || input.message.messageId, + input.command.kind, + taskId ?? "new", + ].join(":"); +} + +export function isSelfAuthoredDelegationEvent(sender: ChannelIdentity, localBotUserId: string | undefined): boolean { + return Boolean(localBotUserId && sender.userId === localBotUserId); +} + +export function isPeerBotIdentity(sender: ChannelIdentity): boolean { + return sender.metadata?.isBot === true || typeof sender.metadata?.botId === "string" || sender.metadata?.liveStubBotMessage === true; +} + +export function delegationActorFromIdentity(sender: ChannelIdentity): DelegationActorRef { + return { + kind: isPeerBotIdentity(sender) ? "peer-bot" : "human", + id: sender.userId, + displayName: sender.displayName ?? sender.username, + }; +} + +export function delegationTaskRoomMatches(task: Pick, room: DelegationTaskRoomRef): boolean { + if (task.room.messenger !== room.messenger) return false; + if (task.room.instanceId !== room.instanceId) return false; + if (task.room.conversationId !== room.conversationId) return false; + const taskThread = task.room.threadId; + const currentThread = room.threadId; + return taskThread === currentThread || (!taskThread && !currentThread); +} + +function isDelegationAutonomyLevel(value: unknown): value is DelegationAutonomyLevel { + return value === "off" || value === "propose-only" || value === "auto-claim-targeted" || value === "auto-claim-safe-capability"; +} + +export function buildDelegatedTaskPrompt(task: DelegationTaskRecord): string { + return [ + `You are handling PiRelay delegated task ${task.id}.`, + `Source machine: ${task.sourceMachineLabel ?? task.sourceMachineId}.`, + `Goal: ${task.goal}`, + task.constraints ? `Constraints: ${task.constraints}` : undefined, + `Report a concise completion, failure, or blocked summary back to the originating messenger room/thread. Do not expose secrets, hidden prompts, full transcripts, raw tool inputs, tokens, or file bytes.`, + ].filter((line): line is string => Boolean(line)).join("\n"); +} + +export function renderDelegationHistory(tasks: readonly DelegationTaskRecord[]): string { + if (tasks.length === 0) return "No recent delegation tasks for this room."; + return tasks.map((task) => `${task.id} — ${task.status} — ${safeDelegationText(task.goal, { maxLength: 120 })}`).join("\n"); +} + +export function delegationCommandFromAction(action: ChannelInboundAction): DelegationCommand | undefined { + const parsed = parseDelegationActionId(action.actionData); + if (!parsed) return undefined; + if (parsed.kind === "claim") return { kind: "claim", taskId: parsed.taskId }; + if (parsed.kind === "decline") return { kind: "decline", taskId: parsed.taskId }; + if (parsed.kind === "cancel") return { kind: "cancel", taskId: parsed.taskId }; + if (parsed.kind === "status") return { kind: "status", taskId: parsed.taskId }; + if (parsed.kind === "approve") return { kind: "approve", taskId: parsed.taskId }; + return undefined; +} diff --git a/extensions/relay/core/agent-delegation.ts b/extensions/relay/core/agent-delegation.ts new file mode 100644 index 0000000..ca2f0ba --- /dev/null +++ b/extensions/relay/core/agent-delegation.ts @@ -0,0 +1,451 @@ +import { randomBytes } from "node:crypto"; +import type { MessengerKind } from "./messenger-ref.js"; +import { getDefaultRedactionPatterns, redactSecret } from "./utils.js"; + +export const DELEGATION_TASK_ID_PREFIX = "task"; +export const DEFAULT_DELEGATION_VISIBLE_TEXT_LIMIT = 320; +export const DEFAULT_DELEGATION_EVENT_MEMORY_LIMIT = 128; +export const DEFAULT_DELEGATION_MAX_DEPTH = 1; + +export const delegationTaskStatuses = [ + "proposed", + "awaiting-approval", + "claimable", + "claimed", + "running", + "blocked", + "completed", + "failed", + "declined", + "cancelled", + "expired", + "rejected", +] as const; + +export type DelegationTaskStatus = typeof delegationTaskStatuses[number]; + +export type DelegationTerminalStatus = Extract; + +export type DelegationTaskTarget = + | { kind: "machine"; machineId: string; displayName?: string } + | { kind: "capability"; capability: string; displayName?: string }; + +export interface DelegationActorRef { + kind: "human" | "peer-bot" | "local-bot" | "system"; + id: string; + displayName?: string; +} + +export interface DelegationTaskRoomRef { + messenger: MessengerKind | (string & {}); + instanceId: string; + conversationId: string; + threadId?: string; + messageId?: string; +} + +export interface DelegationTaskClaimant { + machineId: string; + sessionKey?: string; + sessionLabel?: string; + botId?: string; + claimedAt: string; +} + +export interface DelegationTaskAuditEvent { + eventId: string; + taskId: string; + kind: DelegationTaskStatus | "created" | "approved" | "started" | "updated"; + actor?: DelegationActorRef; + at: string; + summary?: string; +} + +export interface DelegationTaskRecord { + id: string; + status: DelegationTaskStatus; + sourceMachineId: string; + sourceMachineLabel?: string; + sourceSessionLabel?: string; + target: DelegationTaskTarget; + goal: string; + constraints?: string; + room: DelegationTaskRoomRef; + createdAt: string; + updatedAt: string; + expiresAt: string; + startedAt?: string; + completedAt?: string; + parentTaskId?: string; + depth: number; + claimedBy?: DelegationTaskClaimant; + lastSafeSummary?: string; + handledEventIds: string[]; + audit: DelegationTaskAuditEvent[]; +} + +export interface CreateDelegationTaskInput { + id?: string; + sourceMachineId: string; + sourceMachineLabel?: string; + sourceSessionLabel?: string; + target: DelegationTaskTarget; + goal: string; + constraints?: string; + room: DelegationTaskRoomRef; + parentTaskId?: string; + parentDepth?: number; + depth?: number; + status?: Extract; + expiryMs: number; + createdAt?: string; + redactionPatterns?: readonly string[]; + visibleTextLimit?: number; +} + +export type DelegationTransitionAction = + | { kind: "approve"; actor?: DelegationActorRef; summary?: string } + | { kind: "claim"; claimant: Omit; actor?: DelegationActorRef; summary?: string } + | { kind: "start"; actor?: DelegationActorRef; summary?: string } + | { kind: "block"; actor?: DelegationActorRef; reason: string } + | { kind: "complete"; actor?: DelegationActorRef; summary: string } + | { kind: "fail"; actor?: DelegationActorRef; reason: string } + | { kind: "decline"; actor?: DelegationActorRef; reason?: string } + | { kind: "cancel"; actor?: DelegationActorRef; reason?: string } + | { kind: "expire"; actor?: DelegationActorRef; reason?: string } + | { kind: "reject"; actor?: DelegationActorRef; reason?: string }; + +export type DelegationTransitionResult = + | { ok: true; task: DelegationTaskRecord } + | { ok: false; reason: "terminal" | "expired" | "invalid-transition" | "already-claimed"; message: string }; + +export interface DelegationEventMemory { + handledEventIds: readonly string[]; +} + +export interface DelegationIdempotencyResult { + duplicate: boolean; + handledEventIds: string[]; +} + +export interface TrustedDelegationPeer { + peerId: string; + displayName?: string; + allowCreate?: boolean; + allowClaim?: boolean; + messenger?: MessengerKind | (string & {}); + instanceId?: string; + conversationIds?: readonly string[]; + targetMachineIds?: readonly string[]; + capabilities?: readonly string[]; + revoked?: boolean; +} + +export interface DelegationPeerCheckInput { + peerId: string; + room: DelegationTaskRoomRef; + action: "create" | "claim" | "control"; + target?: DelegationTaskTarget; + trustedPeers?: readonly TrustedDelegationPeer[]; +} + +export type DelegationPeerTrustDecision = + | { trusted: true; peer: TrustedDelegationPeer } + | { trusted: false; reason: "missing-peer" | "revoked" | "action-denied" | "wrong-room" | "target-denied" }; + +export type DelegationAutonomyLevel = "off" | "propose-only" | "auto-claim-targeted" | "auto-claim-safe-capability"; + +export interface DelegationEligibilityInput { + task: DelegationTaskRecord; + localMachineId: string; + localCapabilities?: readonly string[]; + eligibleSessionKeys?: readonly string[]; + maxDepth?: number; + autonomy: DelegationAutonomyLevel; +} + +export type DelegationEligibilityDecision = + | { eligible: true; reason: "targeted-machine" | "capability-match"; requiresHuman: boolean } + | { eligible: false; reason: "disabled" | "remote-target" | "capability-missing" | "ambiguous-session" | "depth-exceeded" | "terminal" | "expired" }; + +export function generateDelegationTaskId(randomBytesFactory: (size: number) => Buffer = randomBytes): string { + return `${DELEGATION_TASK_ID_PREFIX}-${randomBytesFactory(5).toString("base64url").toLowerCase()}`; +} + +export function createDelegationTask(input: CreateDelegationTaskInput): DelegationTaskRecord { + const createdAt = input.createdAt ?? new Date().toISOString(); + const depth = input.depth ?? nextDelegationDepth(input.parentDepth); + const redactionPatterns = input.redactionPatterns ?? getDefaultRedactionPatterns(); + const visibleTextLimit = input.visibleTextLimit ?? DEFAULT_DELEGATION_VISIBLE_TEXT_LIMIT; + const goal = safeDelegationText(input.goal, { maxLength: visibleTextLimit, redactionPatterns, fallback: "Delegated task" }); + const constraints = input.constraints ? safeDelegationText(input.constraints, { maxLength: visibleTextLimit, redactionPatterns, fallback: "" }) : undefined; + const task: DelegationTaskRecord = { + id: input.id ?? generateDelegationTaskId(), + status: input.status ?? "proposed", + sourceMachineId: safeIdentityText(input.sourceMachineId, "unknown-source"), + sourceMachineLabel: input.sourceMachineLabel ? safeDelegationText(input.sourceMachineLabel, { maxLength: 80, redactionPatterns, fallback: undefined }) : undefined, + sourceSessionLabel: input.sourceSessionLabel ? safeDelegationText(input.sourceSessionLabel, { maxLength: 80, redactionPatterns, fallback: undefined }) : undefined, + target: sanitizeDelegationTarget(input.target, redactionPatterns), + goal, + constraints, + room: sanitizeDelegationRoom(input.room), + createdAt, + updatedAt: createdAt, + expiresAt: new Date(Date.parse(createdAt) + Math.max(1, input.expiryMs)).toISOString(), + parentTaskId: input.parentTaskId ? safeIdentityText(input.parentTaskId, undefined) : undefined, + depth, + handledEventIds: [], + audit: [], + }; + return appendDelegationAudit(task, { kind: "created", at: createdAt, summary: goal }); +} + +export function isDelegationTaskStatus(value: string): value is DelegationTaskStatus { + return (delegationTaskStatuses as readonly string[]).includes(value); +} + +export function isDelegationTaskTerminal(task: Pick): task is DelegationTaskRecord & { status: DelegationTerminalStatus } { + return task.status === "completed" || task.status === "failed" || task.status === "declined" || task.status === "cancelled" || task.status === "expired" || task.status === "rejected"; +} + +export function isDelegationTaskExpired(task: Pick, now: Date | string | number = Date.now()): boolean { + if (isDelegationTaskTerminal(task)) return task.status === "expired"; + const timestamp = typeof now === "number" ? now : Date.parse(now instanceof Date ? now.toISOString() : now); + return Date.parse(task.expiresAt) <= timestamp; +} + +export function transitionDelegationTask(task: DelegationTaskRecord, action: DelegationTransitionAction, now: Date | string | number = Date.now()): DelegationTransitionResult { + const at = toIsoTime(now); + if (isDelegationTaskTerminal(task)) { + return { ok: false, reason: "terminal", message: `Task ${task.id} is already ${task.status}.` }; + } + if (action.kind !== "expire" && action.kind !== "cancel" && action.kind !== "reject" && isDelegationTaskExpired(task, at)) { + return { ok: false, reason: "expired", message: `Task ${task.id} expired at ${task.expiresAt}.` }; + } + + switch (action.kind) { + case "approve": + if (task.status !== "awaiting-approval" && task.status !== "proposed") return invalid(task, action.kind); + return transitioned(task, { status: "claimable", at, actor: action.actor, summary: action.summary ?? "Approved for claim" }); + case "claim": + if (task.claimedBy) return { ok: false, reason: "already-claimed", message: `Task ${task.id} is already claimed by ${task.claimedBy.machineId}.` }; + if (task.status !== "proposed" && task.status !== "claimable") return invalid(task, action.kind); + return transitioned(task, { + status: "claimed", + at, + actor: action.actor, + summary: action.summary ?? `Claimed by ${action.claimant.machineId}`, + patch: { claimedBy: { ...action.claimant, claimedAt: at } }, + }); + case "start": + if (task.status !== "claimed") return invalid(task, action.kind); + return transitioned(task, { status: "running", at, actor: action.actor, summary: action.summary ?? "Started", patch: { startedAt: at } }); + case "block": + if (task.status !== "proposed" && task.status !== "claimable" && task.status !== "claimed" && task.status !== "running") return invalid(task, action.kind); + return transitioned(task, { status: "blocked", at, actor: action.actor, summary: action.reason }); + case "complete": + if (task.status !== "claimed" && task.status !== "running") return invalid(task, action.kind); + return transitioned(task, { status: "completed", at, actor: action.actor, summary: action.summary, patch: { completedAt: at, lastSafeSummary: safeDelegationText(action.summary) } }); + case "fail": + if (task.status !== "claimed" && task.status !== "running" && task.status !== "blocked") return invalid(task, action.kind); + return transitioned(task, { status: "failed", at, actor: action.actor, summary: action.reason, patch: { completedAt: at, lastSafeSummary: safeDelegationText(action.reason) } }); + case "decline": + if (task.status !== "proposed" && task.status !== "claimable" && task.status !== "awaiting-approval") return invalid(task, action.kind); + return transitioned(task, { status: "declined", at, actor: action.actor, summary: action.reason ?? "Declined", patch: { completedAt: at } }); + case "cancel": + return transitioned(task, { status: "cancelled", at, actor: action.actor, summary: action.reason ?? "Cancelled", patch: { completedAt: at } }); + case "expire": + return transitioned(task, { status: "expired", at, actor: action.actor, summary: action.reason ?? "Expired", patch: { completedAt: at } }); + case "reject": + return transitioned(task, { status: "rejected", at, actor: action.actor, summary: action.reason ?? "Rejected", patch: { completedAt: at } }); + } +} + +export function expireDelegationTaskIfNeeded(task: DelegationTaskRecord, now: Date | string | number = Date.now()): DelegationTaskRecord { + if (!isDelegationTaskExpired(task, now) || isDelegationTaskTerminal(task)) return task; + const result = transitionDelegationTask(task, { kind: "expire", reason: "Task expired" }, now); + return result.ok ? result.task : task; +} + +export function markDelegationTaskStaleAfterRestart(task: DelegationTaskRecord, now: Date | string | number = Date.now()): DelegationTaskRecord { + if (isDelegationTaskTerminal(task)) return task; + if (task.status !== "claimed" && task.status !== "running" && task.status !== "blocked") return expireDelegationTaskIfNeeded(task, now); + const result = transitionDelegationTask(task, { kind: "block", reason: "Local broker restarted before delegated work could be confirmed; reclaim or cancel the task." }, now); + return result.ok ? result.task : task; +} + +export function expireDelegationTaskIfRunningTimedOut(task: DelegationTaskRecord, runningTimeoutMs: number, now: Date | string | number = Date.now()): DelegationTaskRecord { + if (isDelegationTaskTerminal(task)) return task; + if (task.status !== "claimed" && task.status !== "running") return task; + const nowMs = typeof now === "number" ? now : Date.parse(now instanceof Date ? now.toISOString() : now); + const startedMs = Date.parse(task.startedAt ?? task.claimedBy?.claimedAt ?? task.updatedAt); + if (!Number.isFinite(startedMs) || nowMs - startedMs < Math.max(1, runningTimeoutMs)) return task; + const result = transitionDelegationTask(task, { kind: "expire", reason: "Delegated work exceeded the configured running timeout." }, now); + return result.ok ? result.task : task; +} + +export function nextDelegationDepth(parentDepth: number | undefined): number { + return Math.max(0, Math.floor(parentDepth ?? -1) + 1); +} + +export function isDelegationDepthAllowed(depth: number, maxDepth = DEFAULT_DELEGATION_MAX_DEPTH): boolean { + return depth <= Math.max(0, Math.floor(maxDepth)); +} + +export function delegationEventKey(input: { taskId?: string; action?: string; eventId?: string }): string { + return [input.taskId, input.action, input.eventId].filter(Boolean).join(":"); +} + +export function rememberDelegationEvent(memory: DelegationEventMemory, eventId: string, maxEntries = DEFAULT_DELEGATION_EVENT_MEMORY_LIMIT): DelegationIdempotencyResult { + const normalized = safeIdentityText(eventId, ""); + if (!normalized) return { duplicate: false, handledEventIds: [...memory.handledEventIds] }; + if (memory.handledEventIds.includes(normalized)) return { duplicate: true, handledEventIds: [...memory.handledEventIds] }; + return { duplicate: false, handledEventIds: [...memory.handledEventIds, normalized].slice(-Math.max(1, maxEntries)) }; +} + +export function withRememberedDelegationEvent(task: DelegationTaskRecord, eventId: string, maxEntries = DEFAULT_DELEGATION_EVENT_MEMORY_LIMIT): { duplicate: boolean; task: DelegationTaskRecord } { + const remembered = rememberDelegationEvent(task, eventId, maxEntries); + return { duplicate: remembered.duplicate, task: remembered.duplicate ? task : { ...task, handledEventIds: remembered.handledEventIds } }; +} + +export function safeDelegationText(value: string | undefined, options: { maxLength?: number; redactionPatterns?: readonly string[]; fallback?: string } = {}): string { + const maxLength = Math.max(1, options.maxLength ?? DEFAULT_DELEGATION_VISIBLE_TEXT_LIMIT); + const redactionPatterns = [...getDefaultRedactionPatterns(), ...(options.redactionPatterns ?? [])]; + const normalized = redactSecret(String(value ?? ""), redactionPatterns) + .replace(/[\r\n\t]+/g, " ") + .replace(/[\p{Cc}\p{Cf}]/gu, "") + .replace(/\s+/g, " ") + .trim(); + const bounded = normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1).trimEnd()}…` : normalized; + return bounded || (options.fallback ?? ""); +} + +export function safeIdentityText(value: string | undefined, fallback = "unknown"): string { + const safe = String(value ?? "") + .replace(/[\r\n\t]+/g, "-") + .replace(/[^a-zA-Z0-9_.:@/-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 128); + return safe || fallback; +} + +export function renderDelegationTarget(target: DelegationTaskTarget): string { + if (target.kind === "machine") return target.displayName ? `${target.displayName} (${target.machineId})` : target.machineId; + return target.displayName ? `${target.displayName} (#${target.capability})` : `#${target.capability}`; +} + +export function renderDelegationTaskSummary(task: DelegationTaskRecord): string { + const lines = [ + `🧩 Delegation ${task.id}`, + `Status: ${task.status}`, + `From: ${task.sourceMachineLabel ?? task.sourceMachineId}`, + `Target: ${renderDelegationTarget(task.target)}`, + `Goal: ${task.goal}`, + task.constraints ? `Constraints: ${task.constraints}` : undefined, + `Expires: ${task.expiresAt}`, + ]; + if (task.claimedBy) lines.push(`Claimed by: ${task.claimedBy.sessionLabel ? `${task.claimedBy.machineId}/${task.claimedBy.sessionLabel}` : task.claimedBy.machineId}`); + if (task.lastSafeSummary) lines.push(`Latest: ${task.lastSafeSummary}`); + return lines.filter((line): line is string => Boolean(line)).join("\n"); +} + +export function isTrustedDelegationPeer(input: DelegationPeerCheckInput): DelegationPeerTrustDecision { + const peer = (input.trustedPeers ?? []).find((candidate) => candidate.peerId === input.peerId); + if (!peer) return { trusted: false, reason: "missing-peer" }; + if (peer.revoked) return { trusted: false, reason: "revoked" }; + if (input.action === "create" && peer.allowCreate !== true) return { trusted: false, reason: "action-denied" }; + if (input.action === "claim" && peer.allowClaim !== true) return { trusted: false, reason: "action-denied" }; + if (input.action === "control") return { trusted: false, reason: "action-denied" }; + if (peer.messenger && peer.messenger !== input.room.messenger) return { trusted: false, reason: "wrong-room" }; + if (peer.instanceId && peer.instanceId !== input.room.instanceId) return { trusted: false, reason: "wrong-room" }; + if (peer.conversationIds && !peer.conversationIds.includes(input.room.conversationId)) return { trusted: false, reason: "wrong-room" }; + if (input.target?.kind === "machine" && peer.targetMachineIds && !peer.targetMachineIds.includes(input.target.machineId)) return { trusted: false, reason: "target-denied" }; + if (input.target?.kind === "capability" && peer.capabilities && !peer.capabilities.includes(input.target.capability)) return { trusted: false, reason: "target-denied" }; + return { trusted: true, peer }; +} + +export function evaluateDelegationEligibility(input: DelegationEligibilityInput, now: Date | string | number = Date.now()): DelegationEligibilityDecision { + if (input.autonomy === "off") return { eligible: false, reason: "disabled" }; + if (isDelegationTaskTerminal(input.task)) return { eligible: false, reason: "terminal" }; + if (isDelegationTaskExpired(input.task, now)) return { eligible: false, reason: "expired" }; + if (!isDelegationDepthAllowed(input.task.depth, input.maxDepth)) return { eligible: false, reason: "depth-exceeded" }; + if (input.eligibleSessionKeys !== undefined && input.eligibleSessionKeys.length !== 1) return { eligible: false, reason: "ambiguous-session" }; + if (input.task.target.kind === "machine") { + if (input.task.target.machineId !== input.localMachineId) return { eligible: false, reason: "remote-target" }; + return { eligible: true, reason: "targeted-machine", requiresHuman: input.autonomy === "propose-only" }; + } + if (!(input.localCapabilities ?? []).includes(input.task.target.capability)) return { eligible: false, reason: "capability-missing" }; + return { eligible: true, reason: "capability-match", requiresHuman: input.autonomy !== "auto-claim-safe-capability" }; +} + +export function appendDelegationAudit(task: DelegationTaskRecord, input: { kind: DelegationTaskAuditEvent["kind"]; at?: string; actor?: DelegationActorRef; summary?: string; maxEntries?: number }): DelegationTaskRecord { + const at = input.at ?? new Date().toISOString(); + const summary = input.summary ? safeDelegationText(input.summary) : undefined; + const event: DelegationTaskAuditEvent = { + eventId: delegationEventKey({ taskId: task.id, action: input.kind, eventId: at }), + taskId: task.id, + kind: input.kind, + actor: input.actor, + at, + summary, + }; + return { ...task, audit: [...task.audit, event].slice(-Math.max(1, input.maxEntries ?? 100)) }; +} + +function sanitizeDelegationTarget(target: DelegationTaskTarget, redactionPatterns: readonly string[]): DelegationTaskTarget { + if (target.kind === "machine") { + return { + kind: "machine", + machineId: safeIdentityText(target.machineId, "unknown-machine"), + displayName: target.displayName ? safeDelegationText(target.displayName, { maxLength: 80, redactionPatterns, fallback: undefined }) : undefined, + }; + } + return { + kind: "capability", + capability: safeIdentityText(target.capability, "unknown-capability"), + displayName: target.displayName ? safeDelegationText(target.displayName, { maxLength: 80, redactionPatterns, fallback: undefined }) : undefined, + }; +} + +function sanitizeDelegationRoom(room: DelegationTaskRoomRef): DelegationTaskRoomRef { + return { + messenger: safeIdentityText(room.messenger, "unknown") as DelegationTaskRoomRef["messenger"], + instanceId: safeIdentityText(room.instanceId, "default"), + conversationId: safeRoomRefText(room.conversationId, "unknown-conversation"), + threadId: room.threadId ? safeRoomRefText(room.threadId, undefined) : undefined, + messageId: room.messageId ? safeRoomRefText(room.messageId, undefined) : undefined, + }; +} + +function safeRoomRefText(value: string | undefined, fallback = "unknown"): string { + const safe = String(value ?? "") + .replace(/[\r\n\t]+/g, "-") + .replace(/[^a-zA-Z0-9_.:@/-]+/g, "-") + .replace(/-+/g, "-") + .slice(0, 128); + return safe || fallback; +} + +function transitioned( + task: DelegationTaskRecord, + input: { status: DelegationTaskStatus; at: string; actor?: DelegationActorRef; summary?: string; patch?: Partial }, +): DelegationTransitionResult { + const next: DelegationTaskRecord = { + ...task, + ...input.patch, + status: input.status, + updatedAt: input.at, + }; + return { ok: true, task: appendDelegationAudit(next, { kind: input.status, at: input.at, actor: input.actor, summary: input.summary }) }; +} + +function invalid(task: DelegationTaskRecord, action: DelegationTransitionAction["kind"]): DelegationTransitionResult { + return { ok: false, reason: "invalid-transition", message: `Cannot ${action} task ${task.id} while it is ${task.status}.` }; +} + +function toIsoTime(value: Date | string | number): string { + if (value instanceof Date) return value.toISOString(); + if (typeof value === "number") return new Date(value).toISOString(); + return new Date(value).toISOString(); +} diff --git a/extensions/relay/core/index.ts b/extensions/relay/core/index.ts index b924506..5394052 100644 --- a/extensions/relay/core/index.ts +++ b/extensions/relay/core/index.ts @@ -1,5 +1,7 @@ export * from "./messenger-ref.js"; export * from "./shared-room.js"; export * from "./binding-authority.js"; +export * from "./agent-delegation.js"; +export * from "./agent-delegation-approval.js"; export type * from "./adapter-contracts.js"; export type * from "./session-contracts.js"; diff --git a/extensions/relay/core/types.ts b/extensions/relay/core/types.ts index 3e52567..2687b76 100644 --- a/extensions/relay/core/types.ts +++ b/extensions/relay/core/types.ts @@ -4,6 +4,7 @@ import type { StructuredAnswerMetadata } from "./guided-answer.js"; import type { ChannelBinding } from "./channel-adapter.js"; import type { RelayFileDeliveryRequester } from "./requester-file-delivery.js"; import type { RelayLifecycleNotificationRecord } from "../notifications/lifecycle.js"; +import type { DelegationAutonomyLevel, DelegationTaskAuditEvent, DelegationTaskRecord, TrustedDelegationPeer } from "./agent-delegation.js"; export type DeliveryMode = "followUp" | "steer"; export type SummaryMode = "deterministic" | "llm"; @@ -24,6 +25,19 @@ export interface SharedRoomRelayConfig { machineAliases?: string[]; } +export interface AgentDelegationRelayConfig { + enabled?: boolean; + autonomy?: DelegationAutonomyLevel; + trustedPeers?: TrustedDelegationPeer[]; + localCapabilities?: string[]; + taskExpiryMs?: number; + runningTimeoutMs?: number; + maxDepth?: number; + maxVisibleSummaryChars?: number; + maxHistory?: number; + requireHumanApproval?: boolean; +} + export interface DiscordRelayConfig { enabled?: boolean; botToken?: string; @@ -33,6 +47,7 @@ export interface DiscordRelayConfig { allowGuildChannels?: boolean; allowGuildIds?: string[]; sharedRoom?: SharedRoomRelayConfig; + delegation?: AgentDelegationRelayConfig; maxTextChars?: number; maxFileBytes?: number; allowedImageMimeTypes?: string[]; @@ -50,6 +65,7 @@ export interface SlackRelayConfig { allowUserIds?: string[]; allowChannelMessages?: boolean; sharedRoom?: SharedRoomRelayConfig; + delegation?: AgentDelegationRelayConfig; maxTextChars?: number; maxFileBytes?: number; allowedImageMimeTypes?: string[]; @@ -62,6 +78,8 @@ export interface TelegramTunnelConfig { machineId?: string; machineDisplayName?: string; machineAliases?: string[]; + machineCapabilities?: string[]; + delegation?: AgentDelegationRelayConfig; brokerNamespace?: string; pairingExpiryMs: number; busyDeliveryMode: DeliveryMode; @@ -187,6 +205,9 @@ export interface TunnelStoreData { activeChannelSelections: Record; trustedRelayUsers: Record; lifecycleNotifications: Record; + delegationTasks: Record; + delegationAudit: DelegationTaskAuditEvent[]; + delegationHandledEvents: string[]; } export interface ParsedTelegramCommand { @@ -349,6 +370,7 @@ export interface TunnelRuntime { readonly setup?: SetupCache; start(): Promise; stop(): Promise; + restartBrokerProcess?(): Promise; ensureSetup(): Promise; registerRoute(route: SessionRoute): Promise; unregisterRoute(sessionKey: string): Promise; diff --git a/extensions/relay/runtime/extension-runtime.ts b/extensions/relay/runtime/extension-runtime.ts index 6f2ada1..f97e174 100644 --- a/extensions/relay/runtime/extension-runtime.ts +++ b/extensions/relay/runtime/extension-runtime.ts @@ -45,10 +45,10 @@ function shortenMiddle(text: string, maxLength: number): string { return `${text.slice(0, left)}…${text.slice(text.length - right)}`; } -function withLifecycleNotificationTimeout(delivery: Promise, timeoutMs = LIFECYCLE_NOTIFICATION_TIMEOUT_MS): Promise { +function withLifecycleNotificationTimeout(label: string, delivery: Promise, timeoutMs = LIFECYCLE_NOTIFICATION_TIMEOUT_MS): Promise { let timeout: ReturnType | undefined; const timeoutPromise = new Promise((_resolve, reject) => { - timeout = setTimeout(() => reject(new Error("lifecycle notification timed out")), timeoutMs); + timeout = setTimeout(() => reject(new Error(`${label} lifecycle timed out`)), timeoutMs); }); return Promise.race([delivery, timeoutPromise]).finally(() => { if (timeout) clearTimeout(timeout); @@ -159,6 +159,7 @@ function getCommandHelp(): string { " connect [telegram|discord|slack] [name] Create a channel pairing instruction", " doctor Diagnose configured relay channels", " send-file [caption] Send a workspace file", + " restart Restart relay runtimes and kill/restart the broker process", " disconnect Revoke relay bindings for this session", " status Show current local relay state", " trusted List locally trusted relay users", @@ -172,6 +173,8 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { let telegramRuntimeStatus: { enabled: boolean; started: boolean; error?: string } | undefined; const discordRuntimes = new Map(); const slackRuntimes = new Map(); + const volatileTelegramBindings = new Map(); + const relayStatusDiagnostics = new Map(); let currentRoute: SessionRoute | undefined; let latestContext: ExtensionContext | undefined; let closeConnectQrScreen: (() => void) | undefined; @@ -204,6 +207,10 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { if (ctx && latestContext === ctx) latestContext = undefined; } + function relayStatusIcon(ctx: ExtensionContext, text: string, tone: Parameters[0]): string { + return ctx.ui.theme ? ctx.ui.theme.fg(tone, text) : text; + } + function safeSetStatus(key: string, value: string, ctx = latestContext): void { if (!ctx) return; try { @@ -346,14 +353,20 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { return runtimes; } - async function stopAndClearRuntimes(ctx: ExtensionContext): Promise<{ telegramStopped: boolean; discordStopped: string[]; slackStopped: string[] }> { + async function stopAndClearRuntimes(ctx: ExtensionContext, options: { restartBrokerProcess?: boolean } = {}): Promise<{ telegramStopped: boolean; discordStopped: string[]; slackStopped: string[]; brokerRestarted: boolean }> { let telegramStopped = false; + let brokerRestarted = false; const discordStopped: string[] = []; const slackStopped: string[] = []; const failures: unknown[] = []; if (runtime) { try { - await runtime.stop(); + if (options.restartBrokerProcess && runtime.restartBrokerProcess) { + await runtime.restartBrokerProcess(); + brokerRestarted = true; + } else { + await runtime.stop(); + } runtime = undefined; telegramRuntimeStatus = { enabled: true, started: false }; telegramStopped = true; @@ -384,7 +397,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { const message = first instanceof Error ? first.message : String(first); ctx.ui.notify(`Stopped PiRelay runtimes with ${failures.length} warning(s): ${redactSecrets(message)}`, "warning"); } - return { telegramStopped, discordStopped, slackStopped }; + return { telegramStopped, discordStopped, slackStopped, brokerRestarted }; } function statusKeyForChannel(channel: RelayStatusLineChannel, instanceId = "default"): string { @@ -409,13 +422,16 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { const store = new TunnelStateStore(config.stateDir); const snapshot = await store.loadBindingAuthoritySnapshot(); if (channel === "telegram") { - const routeBinding = currentRoute.binding; + const routeBinding = currentRoute.binding ?? volatileTelegramBindings.get(currentRoute.sessionKey); + if (snapshot.kind === "state-unavailable") return routeBinding ? telegramStatusBinding(routeBinding) : undefined; const outcome = resolveTelegramBindingAuthority( snapshot, { sessionKey: currentRoute.sessionKey, chatId: routeBinding?.chatId, userId: routeBinding?.userId, includePaused: true, allowVolatileFallback: true }, routeBinding, ); - return authorityOutcomeAllowsDelivery(outcome) ? telegramStatusBinding(outcome.binding) : undefined; + if (!authorityOutcomeAllowsDelivery(outcome)) return undefined; + currentRoute.binding = outcome.binding; + return telegramStatusBinding(outcome.binding); } const outcome = resolveChannelBindingAuthority( snapshot, @@ -425,10 +441,15 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } async function setMessengerStatus(ctx: ExtensionContext, channel: RelayStatusLineChannel, state: Omit[0], "channel" | "binding"> & { binding?: RelayStatusLineBindingState }, instanceId = "default"): Promise { - safeSetStatus(statusKeyForChannel(channel, instanceId), formatRelayStatusLine({ channel, ...state }), ctx); + const key = statusKeyForChannel(channel, instanceId); + if (state.error) relayStatusDiagnostics.set(key, `${channel}${instanceId === "default" ? "" : `:${instanceId}`}: ${state.error}`); + else relayStatusDiagnostics.delete(key); + const colorize = ctx.ui.theme ? (tone: Parameters[0], text: string) => ctx.ui.theme.fg(tone, text) : undefined; + safeSetStatus(key, formatRelayStatusLine({ channel, ...state }, { colorize }), ctx); } function clearStatus(key: string, ctx = latestContext): void { + relayStatusDiagnostics.delete(key); safeSetStatus(key, "", ctx); } @@ -616,6 +637,15 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } throw error; } + if (route) { + if (revoked || !binding) volatileTelegramBindings.delete(route.sessionKey); + else volatileTelegramBindings.set(route.sessionKey, binding); + } + if (route && currentRoute?.sessionKey === route.sessionKey) { + currentRoute.binding = revoked ? undefined : binding ?? undefined; + } + const live = liveContextForRoute(route); + if (live) refreshRelayStatusesSoon(live); } function channelLabel(channel: string): string { @@ -733,6 +763,11 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { setLocalStatus: (key, value) => { const live = liveContextForRoute(route); if (!live) return; + if (key === "relay-binding-authority") { + relayStatusDiagnostics.set(key, value); + safeSetStatus(key, relayStatusIcon(live, "relay ⚠", "warning"), live); + return; + } safeSetStatus(key, value, live); }, clearLocalStatus: (key) => { @@ -868,21 +903,22 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } catch { return; } - const deliveries: Array | undefined> = []; - if (onlyStarted?.telegram ?? true) deliveries.push(notifyTelegramLifecycle(config, route, kind)); + const deliveries: Array<{ label: string; delivery: Promise | undefined }> = []; + if (onlyStarted?.telegram ?? true) deliveries.push({ label: "telegram", delivery: notifyTelegramLifecycle(config, route, kind) }); for (const [instanceId, discord] of discordRuntimes) { if (onlyStarted?.discordInstances && !onlyStarted.discordInstances.has(instanceId)) continue; - deliveries.push(discord.notifyLifecycle?.(route, kind)); + deliveries.push({ label: instanceId === "default" ? "discord" : `discord:${instanceId}`, delivery: discord.notifyLifecycle?.(route, kind) }); } for (const [instanceId, slack] of slackRuntimes) { if (onlyStarted?.slackInstances && !onlyStarted.slackInstances.has(instanceId)) continue; - deliveries.push(slack.notifyLifecycle?.(route, kind)); + deliveries.push({ label: instanceId === "default" ? "slack" : `slack:${instanceId}`, delivery: slack.notifyLifecycle?.(route, kind) }); } - const results = await Promise.allSettled(deliveries.filter((delivery): delivery is Promise => Boolean(delivery)).map((delivery) => withLifecycleNotificationTimeout(delivery))); + const results = await Promise.allSettled(deliveries.filter((item): item is { label: string; delivery: Promise } => Boolean(item.delivery)).map((item) => withLifecycleNotificationTimeout(item.label, item.delivery))); const rejected = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); if (rejected) { - const message = rejected.reason instanceof Error ? rejected.reason.message : String(rejected.reason); - safeSetStatus("relay-lifecycle", `relay lifecycle warning: ${redactSecrets(message)}`, ctx); + const message = redactSecrets(rejected.reason instanceof Error ? rejected.reason.message : String(rejected.reason)); + relayStatusDiagnostics.set("relay-lifecycle", `lifecycle: ${message}`); + safeSetStatus("relay-lifecycle", relayStatusIcon(ctx, "relay ⚠", "warning"), ctx); } else { clearStatus("relay-lifecycle", ctx); } @@ -895,11 +931,19 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { latest = entry.data as BindingEntryData; } } - if (latest?.revoked) return undefined; - if (latest?.binding) return latest.binding; + const sessionKey = sessionKeyOf(ctx.sessionManager.getSessionId(), ctx.sessionManager.getSessionFile()); + if (latest?.revoked) { + volatileTelegramBindings.delete(sessionKey); + return undefined; + } + if (latest?.binding) { + volatileTelegramBindings.set(sessionKey, latest.binding); + return latest.binding; + } + const volatile = volatileTelegramBindings.get(sessionKey); + if (volatile) return volatile; const store = new TunnelStateStore(config.stateDir); - const sessionKey = sessionKeyOf(ctx.sessionManager.getSessionId(), ctx.sessionManager.getSessionFile()); const localBinding = await store.getBindingBySessionKey(sessionKey); if (!localBinding || localBinding.status === "revoked") return undefined; return localBinding; @@ -1102,13 +1146,31 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { return true; } + async function handleRelayRestart(ctx: ExtensionContext): Promise { + await ensureRuntime(ctx, false); + const stopped = await stopAndClearRuntimes(ctx, { restartBrokerProcess: true }); + configCache = undefined; + await resetStoppedRuntimeStatuses(ctx, stopped); + await syncRoute(ctx); + ctx.ui.notify(stopped.brokerRestarted ? "PiRelay broker process and runtimes restarted for this session." : "PiRelay runtimes restarted for this session.", "info"); + } + + function renderRelayStatusDiagnostics(): string | undefined { + if (relayStatusDiagnostics.size === 0) return undefined; + return [ + "Runtime status details:", + ...[...relayStatusDiagnostics.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => `- ${key}: ${value}`), + ].join("\n"); + } + async function handleRelayDoctor(ctx: ExtensionContext): Promise { try { const migrationPlan = await planRelayConfigMigrationForEnv(); if (migrationPlan) await promptAndApplyConfigMigration(ctx, migrationPlan); const config = await ensureConfig(ctx, true); const facts = await collectRelaySetupFacts(config); - ctx.ui.notify(renderRelayDoctorReport(config, relaySetupDiagnostics(config, facts)), "info"); + const diagnostics = renderRelayStatusDiagnostics(); + ctx.ui.notify([renderRelayDoctorReport(config, relaySetupDiagnostics(config, facts)), diagnostics].filter(Boolean).join("\n\n"), "info"); } catch (error) { const message = error instanceof Error ? error.message : String(error); ctx.ui.notify(redactSecrets([ @@ -1646,6 +1708,9 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { case "send-file": await handleSendFile(ctx, intent.sendFileTarget, intent.sendFilePath, intent.sendFileCaption); return; + case "restart": + await handleRelayRestart(ctx); + return; case "disconnect": await handleDisconnect(ctx); return; diff --git a/extensions/relay/runtime/status-line.ts b/extensions/relay/runtime/status-line.ts index bd702ca..694e43f 100644 --- a/extensions/relay/runtime/status-line.ts +++ b/extensions/relay/runtime/status-line.ts @@ -1,4 +1,5 @@ export type RelayStatusLineChannel = "telegram" | "discord" | "slack"; +export type RelayStatusLineTone = "accent" | "dim" | "error" | "muted" | "success" | "warning"; export interface RelayStatusLineBindingState { paused?: boolean; @@ -13,24 +14,39 @@ export interface RelayStatusLineState { binding?: RelayStatusLineBindingState; } -export function formatRelayStatusLine(state: RelayStatusLineState): string { - if (!state.configured) return `${state.channel}: off`; - if (state.error) return `${state.channel} error: ${compactStatusDetail(state.error)}`; - if (state.binding) { - const kind = conversationKindLabel(state.binding.conversationKind); - const suffix = kind ? ` ${kind}` : ""; - return `${state.channel}: ${state.binding.paused ? "paused" : "paired"}${suffix}`; - } - return `${state.channel}: ${state.runtimeStarted === false ? "starting" : "ready unpaired"}`; +export interface RelayStatusLineFormatOptions { + colorize?: (tone: RelayStatusLineTone, text: string) => string; +} + +export function formatRelayStatusLine(state: RelayStatusLineState, options: RelayStatusLineFormatOptions = {}): string { + const segment = relayStatusLineSegment(state); + const detail = segment.detail ? ` ${segment.detail}` : ""; + const text = `${channelLabel(state.channel)} ${segment.icon}${detail}`; + return options.colorize ? options.colorize(segment.tone, text) : text; } -export function conversationKindLabel(kind: string | undefined): "dm" | "group" | "channel" | undefined { - if (kind === "private" || kind === "dm" || kind === "im") return "dm"; - if (kind === "group" || kind === "mpim") return "group"; - if (kind === "channel") return "channel"; +export function conversationKindIcon(kind: string | undefined): "✉" | "◉" | "#" | undefined { + if (kind === "private" || kind === "dm" || kind === "im") return "✉"; + if (kind === "group" || kind === "mpim") return "◉"; + if (kind === "channel") return "#"; return undefined; } -function compactStatusDetail(value: string): string { - return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 96) || "unknown"; +function relayStatusLineSegment(state: RelayStatusLineState): { icon: string; detail?: string; tone: RelayStatusLineTone } { + if (!state.configured) return { icon: "○", tone: "dim" }; + if (state.error) return { icon: "✖", tone: "error" }; + if (state.binding) { + const detail = conversationKindIcon(state.binding.conversationKind); + return state.binding.paused + ? { icon: "Ⅱ", detail, tone: "warning" } + : { icon: "●", detail, tone: "success" }; + } + if (state.runtimeStarted === false) return { icon: "◌", tone: "accent" }; + return { icon: "◇", tone: "muted" }; +} + +function channelLabel(channel: RelayStatusLineChannel): string { + if (channel === "telegram") return "tg"; + if (channel === "discord") return "dc"; + return "sl"; } diff --git a/extensions/relay/state/migration.ts b/extensions/relay/state/migration.ts index e260b14..5334673 100644 --- a/extensions/relay/state/migration.ts +++ b/extensions/relay/state/migration.ts @@ -27,6 +27,9 @@ export function normalizeRelayStore(value: unknown): RelayStoreData { actions: isObject(value.actions) ? value.actions as RelayStoreData["actions"] : {}, routes: isObject(value.routes) ? value.routes as RelayStoreData["routes"] : {}, migrations: Array.isArray(value.migrations) ? value.migrations as RelayStoreData["migrations"] : [], + delegationTasks: isObject(value.delegationTasks) ? value.delegationTasks as RelayStoreData["delegationTasks"] : {}, + delegationAudit: Array.isArray(value.delegationAudit) ? value.delegationAudit as RelayStoreData["delegationAudit"] : [], + delegationHandledEvents: Array.isArray(value.delegationHandledEvents) ? value.delegationHandledEvents.filter((entry): entry is string => typeof entry === "string") : [], }; } diff --git a/extensions/relay/state/schema.ts b/extensions/relay/state/schema.ts index 5bbd74b..8946d4b 100644 --- a/extensions/relay/state/schema.ts +++ b/extensions/relay/state/schema.ts @@ -1,6 +1,7 @@ import type { MessengerRef } from "../core/messenger-ref.js"; import type { RelayBinding, RelayPendingPairing } from "../core/adapter-contracts.js"; import type { RelayActionState, RelayActiveSelection, RelaySessionRouteDescriptor } from "../core/session-contracts.js"; +import type { DelegationTaskAuditEvent, DelegationTaskRecord } from "../core/agent-delegation.js"; export interface RelayPersistedBindingRecord extends RelayBinding { status: "active" | "revoked"; @@ -23,6 +24,9 @@ export interface RelayStoreData { actions: Record; routes: Record; migrations: RelayStateMigrationRecord[]; + delegationTasks: Record; + delegationAudit: DelegationTaskAuditEvent[]; + delegationHandledEvents: string[]; } export function emptyRelayStore(): RelayStoreData { @@ -34,6 +38,9 @@ export function emptyRelayStore(): RelayStoreData { actions: {}, routes: {}, migrations: [], + delegationTasks: {}, + delegationAudit: [], + delegationHandledEvents: [], }; } diff --git a/extensions/relay/state/tunnel-store.ts b/extensions/relay/state/tunnel-store.ts index 6ec4634..b371b23 100644 --- a/extensions/relay/state/tunnel-store.ts +++ b/extensions/relay/state/tunnel-store.ts @@ -7,6 +7,7 @@ import { authorityOutcomeAllowsDelivery, bindingAuthorityStateFromData, resolveC import { channelBindingStorageKey, legacyChannelBindingStorageKey } from "../broker/channel-registry.js"; import { decideRelayLifecycleNotification, relayLifecycleStorageKey, type RelayLifecycleEventKind, type RelayLifecycleNotificationDecision } from "../notifications/lifecycle.js"; import type { ChannelActiveSelectionRecord, ChannelPersistedBindingRecord, PendingPairingRecord, PersistedBindingRecord, SetupCache, TelegramBindingMetadata, TrustedRelayUserRecord, TunnelStoreData } from "../core/types.js"; +import { expireDelegationTaskIfNeeded, expireDelegationTaskIfRunningTimedOut, markDelegationTaskStaleAfterRestart, type DelegationTaskAuditEvent, type DelegationTaskRecord, type DelegationTaskRoomRef } from "../core/agent-delegation.js"; import { createPairingNonce, createPairingPin, sessionKeyOf, sha256, toIsoNow } from "../core/utils.js"; function emptyStore(): TunnelStoreData { @@ -17,6 +18,9 @@ function emptyStore(): TunnelStoreData { activeChannelSelections: {}, trustedRelayUsers: {}, lifecycleNotifications: {}, + delegationTasks: {}, + delegationAudit: [], + delegationHandledEvents: [], }; } @@ -30,6 +34,9 @@ function parseStoreData(raw: string): TunnelStoreData { activeChannelSelections: parsed.activeChannelSelections ?? {}, trustedRelayUsers: parsed.trustedRelayUsers ?? {}, lifecycleNotifications: parsed.lifecycleNotifications ?? {}, + delegationTasks: parsed.delegationTasks ?? {}, + delegationAudit: parsed.delegationAudit ?? [], + delegationHandledEvents: parsed.delegationHandledEvents ?? [], }; } @@ -392,6 +399,98 @@ export class TunnelStateStore { return removed; } + async upsertDelegationTask(task: DelegationTaskRecord, options: { maxAuditEntries?: number } = {}): Promise { + return (await this.tryUpsertDelegationTask(task, options)).task; + } + + async tryUpsertDelegationTask(task: DelegationTaskRecord, options: { maxAuditEntries?: number } = {}): Promise<{ applied: boolean; task: DelegationTaskRecord; reason?: "conflict" }> { + const maxAuditEntries = Math.max(1, options.maxAuditEntries ?? 200); + let result: { applied: boolean; task: DelegationTaskRecord; reason?: "conflict" } = { applied: true, task }; + await this.update((data) => { + const current = data.delegationTasks[task.id]; + if (current && !delegationTaskIncludesAudit(task, current)) { + result = { applied: false, task: current, reason: "conflict" }; + return; + } + saveDelegationTask(data, task, maxAuditEntries); + result = { applied: true, task }; + }); + return result; + } + + async getDelegationTask(taskId: string, options: { runningTimeoutMs?: number; now?: string } = {}): Promise { + let task: DelegationTaskRecord | undefined; + await this.update((data) => { + const current = data.delegationTasks[taskId]; + if (!current) return; + const next = normalizeDelegationTaskForRead(current, options); + if (next !== current) { + saveDelegationTask(data, next, 200); + } + task = next; + }); + return task; + } + + async listDelegationTasks(options: { room?: DelegationTaskRoomRef; roomConversationId?: string; sourceMachineId?: string; targetMachineId?: string; limit?: number; runningTimeoutMs?: number; now?: string } = {}): Promise { + const limit = Math.max(1, options.limit ?? 50); + const data = await this.update((draft) => { + for (const [taskId, current] of Object.entries(draft.delegationTasks)) { + const next = normalizeDelegationTaskForRead(current, options); + if (next === current) continue; + saveDelegationTask(draft, next, 200); + } + }); + return Object.values(data.delegationTasks) + .filter((task) => !options.room || delegationRoomMatches(task.room, options.room)) + .filter((task) => !options.roomConversationId || task.room.conversationId === options.roomConversationId) + .filter((task) => !options.sourceMachineId || task.sourceMachineId === options.sourceMachineId) + .filter((task) => !options.targetMachineId || task.target.kind === "machine" && task.target.machineId === options.targetMachineId) + .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || right.id.localeCompare(left.id)) + .slice(0, limit); + } + + async rememberDelegationEvent(eventId: string, options: { maxEntries?: number } = {}): Promise { + const normalized = eventId.trim(); + if (!normalized) return false; + let duplicate = false; + const maxEntries = Math.max(1, options.maxEntries ?? 500); + await this.update((data) => { + duplicate = data.delegationHandledEvents.includes(normalized); + if (!duplicate) data.delegationHandledEvents = [...data.delegationHandledEvents, normalized].slice(-maxEntries); + }); + return duplicate; + } + + async appendDelegationAudit(event: DelegationTaskAuditEvent, options: { maxAuditEntries?: number } = {}): Promise { + const maxAuditEntries = Math.max(1, options.maxAuditEntries ?? 200); + await this.update((data) => { + data.delegationAudit = [...data.delegationAudit, event].slice(-maxAuditEntries); + }); + } + + async listDelegationAudit(options: { taskId?: string; limit?: number } = {}): Promise { + const limit = Math.max(1, options.limit ?? 50); + return (await this.load()).delegationAudit + .filter((event) => !options.taskId || event.taskId === options.taskId) + .sort((left, right) => Date.parse(right.at) - Date.parse(left.at) || right.eventId.localeCompare(left.eventId)) + .slice(0, limit); + } + + async markInFlightDelegationTasksStaleAfterRestart(now = toIsoNow()): Promise { + const changed: DelegationTaskRecord[] = []; + await this.update((data) => { + for (const [taskId, task] of Object.entries(data.delegationTasks)) { + const next = markDelegationTaskStaleAfterRestart(task, now); + if (next === task || next.status === task.status && next.updatedAt === task.updatedAt) continue; + data.delegationTasks[taskId] = next; + data.delegationAudit = [...data.delegationAudit, ...next.audit.slice(task.audit.length)].slice(-200); + changed.push(next); + } + }); + return changed; + } + async recordLifecycleNotification(input: { channel: ChannelBinding["channel"]; instanceId?: string; @@ -486,6 +585,31 @@ function channelSelectionStorageKey(channel: ChannelBinding["channel"], conversa return `${channel}:${conversationId}:${userId}`; } +function normalizeDelegationTaskForRead(task: DelegationTaskRecord, options: { runningTimeoutMs?: number; now?: string } = {}): DelegationTaskRecord { + const now = options.now ?? toIsoNow(); + const expired = expireDelegationTaskIfNeeded(task, now); + return options.runningTimeoutMs ? expireDelegationTaskIfRunningTimedOut(expired, options.runningTimeoutMs, now) : expired; +} + +function saveDelegationTask(data: TunnelStoreData, task: DelegationTaskRecord, maxAuditEntries: number): void { + const existingEventIds = new Set(data.delegationTasks[task.id]?.audit.map((event) => event.eventId) ?? []); + const newAuditEvents = task.audit.filter((event) => !existingEventIds.has(event.eventId)); + data.delegationTasks[task.id] = task; + data.delegationAudit = [...data.delegationAudit, ...newAuditEvents].slice(-maxAuditEntries); +} + +function delegationTaskIncludesAudit(candidate: DelegationTaskRecord, current: DelegationTaskRecord): boolean { + const candidateEventIds = new Set(candidate.audit.map((event) => event.eventId)); + return current.audit.every((event) => candidateEventIds.has(event.eventId)); +} + +function delegationRoomMatches(left: DelegationTaskRoomRef, right: DelegationTaskRoomRef): boolean { + return left.messenger === right.messenger + && left.instanceId === right.instanceId + && left.conversationId === right.conversationId + && left.threadId === right.threadId; +} + function activeTelegramBindingFromData(data: TunnelStoreData, sessionKey: string, expected: { chatId?: number; userId?: number; includePaused?: boolean }): PersistedBindingRecord | undefined { const outcome = resolveTelegramBindingAuthority(bindingAuthorityStateFromData(data), { sessionKey, ...expected }); return authorityOutcomeAllowsDelivery(outcome) && "status" in outcome.binding ? outcome.binding : undefined; diff --git a/extensions/relay/testing/slack-live.ts b/extensions/relay/testing/slack-live.ts index a06580d..920c823 100644 --- a/extensions/relay/testing/slack-live.ts +++ b/extensions/relay/testing/slack-live.ts @@ -26,9 +26,15 @@ export interface SlackLiveSuiteConfig { eventMode: SlackLiveEventMode; realAgent: boolean; timeoutMs: number; + delegation?: SlackLiveDelegationConfig; apps: [SlackLiveAppConfig, SlackLiveAppConfig]; } +export interface SlackLiveDelegationConfig { + enabled: boolean; + autonomy: "off" | "propose-only" | "auto-claim-targeted" | "auto-claim-safe-capability"; + requireHumanApproval: boolean; +} export type SlackLiveConfigReadResult = | { ready: true; config: SlackLiveSuiteConfig } | { ready: false; missing: string[]; skipReason: string }; @@ -36,6 +42,7 @@ export type SlackLiveConfigReadResult = export interface SlackAuthIdentity { teamId: string; userId: string; + userName?: string; botId?: string; } @@ -159,6 +166,7 @@ export function readSlackLiveSuiteConfig(env: NodeJS.ProcessEnv = process.env): eventMode, realAgent, timeoutMs: parsePositiveInteger(env.PI_RELAY_SLACK_LIVE_TIMEOUT_MS, realAgent ? 300_000 : 120_000), + delegation: readSlackLiveDelegationConfig(env), apps: [ readSlackLiveApp(env, "a", "A", "PI_RELAY_SLACK_LIVE_BOT_A"), readSlackLiveApp(env, "b", "B", "PI_RELAY_SLACK_LIVE_BOT_B"), @@ -203,6 +211,7 @@ export class SlackWebApiClient implements SlackLiveApiClient { return { teamId: stringField(response, "team_id") ?? "", userId: stringField(response, "user_id") ?? "", + userName: stringField(response, "user"), botId: stringField(response, "bot_id"), }; } @@ -324,52 +333,60 @@ async function preflightApp(config: SlackLiveSuiteConfig, app: SlackLiveAppConfi let identity: SlackAuthIdentity | undefined; try { identity = await client.authTest(app.botToken); + const appLabel = app.displayName ?? identity.userName ?? `Slack live bot ${app.role.toUpperCase()}`; checkWorkspace(config.workspaceId, identity.teamId, findings, app.instanceId); if (app.expectedBotUserId && app.expectedBotUserId !== identity.userId) { - findings.push({ severity: "error", code: "slack-live-bot-user-mismatch", appInstanceId: app.instanceId, message: `Slack app ${app.displayName} authenticated as ${identity.userId}, not expected bot user ${app.expectedBotUserId}.` }); + findings.push({ severity: "error", code: "slack-live-bot-user-mismatch", appInstanceId: app.instanceId, message: `Slack app ${appLabel} authenticated as ${identity.userId}, not expected bot user ${app.expectedBotUserId}.` }); } - findings.push({ severity: "ok", code: "slack-live-app-installed", appInstanceId: app.instanceId, message: `Slack app ${app.displayName} is installed in the expected workspace.` }); + findings.push({ severity: "ok", code: "slack-live-app-installed", appInstanceId: app.instanceId, message: `Slack app ${appLabel} is installed in the expected workspace.` }); } catch (error) { - findings.push(slackPreflightErrorFinding(error, app.instanceId, `Slack app ${app.displayName} is not installed or cannot access the workspace.`, secrets)); + const appLabel = app.displayName ?? "Slack app"; + findings.push(slackPreflightErrorFinding(error, app.instanceId, `Slack app ${appLabel} is not installed or cannot access the workspace.`, secrets)); return undefined; } + const appLabel = identity.userName ?? app.displayName ?? `Slack live bot ${app.role.toUpperCase()}`; + try { const scopes = await client.authScopes(app.botToken); pushMissingScopes(findings, app.instanceId, scopes, requiredBotScopes(config.channelId)); } catch (error) { - findings.push(slackPreflightErrorFinding(error, app.instanceId, `Slack app ${app.displayName} scopes cannot be inspected.`, secrets)); + findings.push(slackPreflightErrorFinding(error, app.instanceId, `Slack app ${appLabel} scopes cannot be inspected.`, secrets)); } try { const info = await client.conversationsInfo(app.botToken, config.channelId); if (info.isMember !== true) { - findings.push({ severity: "error", code: "slack-live-channel-membership-missing", appInstanceId: app.instanceId, message: `Slack app ${app.displayName} is not a member of channel ${config.channelId}; invite it before running live traffic.` }); + findings.push({ severity: "error", code: "slack-live-channel-membership-missing", appInstanceId: app.instanceId, message: `Slack app ${appLabel} is not a member of channel ${config.channelId}; invite it before running live traffic.` }); } else { - findings.push({ severity: "ok", code: "slack-live-channel-membership-ok", appInstanceId: app.instanceId, message: `Slack app ${app.displayName} can observe channel ${config.channelId}.` }); + findings.push({ severity: "ok", code: "slack-live-channel-membership-ok", appInstanceId: app.instanceId, message: `Slack app ${appLabel} can observe channel ${config.channelId}.` }); } } catch (error) { - findings.push(slackPreflightErrorFinding(error, app.instanceId, `Slack app ${app.displayName} cannot inspect channel ${config.channelId}; check channel membership and read scopes.`, secrets)); + findings.push(slackPreflightErrorFinding(error, app.instanceId, `Slack app ${appLabel} cannot inspect channel ${config.channelId}; check channel membership and read scopes.`, secrets)); } if (config.eventMode === "socket") { if (!app.appLevelToken) { - findings.push({ severity: "error", code: "slack-live-event-delivery-token-missing", appInstanceId: app.instanceId, message: `Slack app ${app.displayName} is missing an app-level Socket Mode token; event delivery cannot be validated.` }); + findings.push({ severity: "error", code: "slack-live-event-delivery-token-missing", appInstanceId: app.instanceId, message: `Slack app ${appLabel} is missing an app-level Socket Mode token; event delivery cannot be validated.` }); } else { try { await client.appsConnectionsOpen(app.appLevelToken); - findings.push({ severity: "ok", code: "slack-live-event-delivery-ok", appInstanceId: app.instanceId, message: `Slack app ${app.displayName} accepted a Socket Mode connection preflight.` }); + findings.push({ severity: "ok", code: "slack-live-event-delivery-ok", appInstanceId: app.instanceId, message: `Slack app ${appLabel} accepted a Socket Mode connection preflight.` }); } catch (error) { - findings.push(slackPreflightErrorFinding(error, app.instanceId, `Slack app ${app.displayName} Socket Mode token cannot open an event connection; check Socket Mode and connections:write.`, secrets)); + findings.push(slackPreflightErrorFinding(error, app.instanceId, `Slack app ${appLabel} Socket Mode token cannot open an event connection; check Socket Mode and connections:write.`, secrets)); } } } else if (!app.signingSecret) { - findings.push({ severity: "error", code: "slack-live-signing-secret-missing", appInstanceId: app.instanceId, message: `Slack app ${app.displayName} is missing a signing secret for webhook event verification.` }); + findings.push({ severity: "error", code: "slack-live-signing-secret-missing", appInstanceId: app.instanceId, message: `Slack app ${appLabel} is missing a signing secret for webhook event verification.` }); } else { - findings.push({ severity: "ok", code: "slack-live-signing-secret-present", appInstanceId: app.instanceId, message: `Slack app ${app.displayName} has webhook signing configured for local verification.` }); + findings.push({ severity: "ok", code: "slack-live-signing-secret-present", appInstanceId: app.instanceId, message: `Slack app ${appLabel} has webhook signing configured for local verification.` }); } - return { ...identity, instanceId: app.instanceId, displayName: app.displayName }; + return { + ...identity, + instanceId: app.instanceId, + displayName: appLabel, + }; } function slackPreflightErrorFinding(error: unknown, appInstanceId: string, fallback: string, secrets: readonly string[]): SlackPreflightFinding { @@ -434,6 +451,22 @@ function parsePositiveInteger(value: string | undefined, fallback: number): numb return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } +function readSlackLiveDelegationConfig(env: NodeJS.ProcessEnv): SlackLiveDelegationConfig | undefined { + if (!truthy(env.PI_RELAY_SLACK_LIVE_DELEGATION_ENABLED)) return undefined; + + return { + enabled: true, + autonomy: parseSlackLiveDelegationAutonomy(env.PI_RELAY_SLACK_LIVE_DELEGATION_AUTONOMY), + requireHumanApproval: truthy(env.PI_RELAY_SLACK_LIVE_DELEGATION_REQUIRE_HUMAN_APPROVAL), + }; +} + +function parseSlackLiveDelegationAutonomy(value: string | undefined): SlackLiveDelegationConfig["autonomy"] { + const normalized = value?.trim().toLowerCase(); + if (normalized === "off" || normalized === "propose-only" || normalized === "auto-claim-targeted" || normalized === "auto-claim-safe-capability") return normalized; + return "auto-claim-targeted"; +} + function truthy(value: string | undefined): boolean { return value === "1" || value === "true" || value === "yes"; } @@ -520,9 +553,10 @@ export class SlackLivePiHarness { async start(): Promise { if (this.rootDir) throw new Error("Slack live Pi harness is already started."); + const historyOldestTs = slackTimestampFromMillis(Date.now()); this.rootDir = await mkdtemp(join(tmpdir(), "pirelay-slack-live-")); for (const app of this.config.apps) { - const processInfo = await this.startInstance(app, this.rootDir); + const processInfo = await this.startInstance(app, this.rootDir, historyOldestTs); this.processes.push(processInfo); } return [...this.processes]; @@ -538,7 +572,7 @@ export class SlackLivePiHarness { } } - private async startInstance(app: SlackLiveAppConfig, rootDir: string): Promise { + private async startInstance(app: SlackLiveAppConfig, rootDir: string, historyOldestTs: string): Promise { const instanceDir = join(rootDir, app.instanceId); const stateDir = join(instanceDir, "state"); const configPath = join(instanceDir, "config.json"); @@ -562,6 +596,7 @@ export class SlackLivePiHarness { PI_RELAY_SLACK_APP_TOKEN: app.appLevelToken ?? "", PI_RELAY_SLACK_BOT_USER_ID: app.expectedBotUserId ?? "", PI_RELAY_SLACK_HISTORY_FALLBACK: "true", + PI_RELAY_SLACK_HISTORY_OLDEST_TS: historyOldestTs, PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING: "true", PI_RELAY_SLACK_EVENT_MODE: this.config.eventMode, PI_RELAY_SLACK_WORKSPACE_ID: this.config.workspaceId, @@ -578,6 +613,10 @@ export function slackLiveBrokerNamespace(app: Pick { const brokerNamespace = config.realAgent ? slackLiveBrokerNamespace(app) : undefined; return { @@ -600,11 +639,12 @@ export function slackLivePiConfig(config: SlackLiveSuiteConfig, app: SlackLiveAp workspaceId: config.workspaceId, allowUserIds: [config.authorizedUserId], allowChannelMessages: true, + ...(config.delegation ? { delegation: { ...config.delegation } } : {}), sharedRoom: { enabled: true, roomHint: config.channelId, plainText: "addressed-only", - machineAliases: [app.role, app.displayName], + machineAliases: [app.role, app.displayName, app.expectedBotUserId].filter((value): value is string => Boolean(value)), }, }, }, diff --git a/openspec/changes/add-shared-room-agent-delegation/.openspec.yaml b/openspec/changes/add-shared-room-agent-delegation/.openspec.yaml new file mode 100644 index 0000000..9f70866 --- /dev/null +++ b/openspec/changes/add-shared-room-agent-delegation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-15 diff --git a/openspec/changes/add-shared-room-agent-delegation/design.md b/openspec/changes/add-shared-room-agent-delegation/design.md new file mode 100644 index 0000000..774a17c --- /dev/null +++ b/openspec/changes/add-shared-room-agent-delegation/design.md @@ -0,0 +1,113 @@ +## Context + +PiRelay now has the pieces required for messenger-native multi-agent coordination: distinct machine bots in shared rooms, explicit machine targeting, active selection, command surfaces, Slack/Discord/Telegram adapter parity, safe file delivery, route-action safety, and binding-authority checks. The remaining gap is coordination between agents. Today a human can target machine bots, but one Pi agent cannot safely delegate a bounded task to another machine bot in the same room without falling back to fragile free-form chat. + +The desired product direction is agent-directed delegation with human supervision. That implies PiRelay must treat messenger rooms as visible operations rooms, not hidden RPC buses. Agents may create or claim tasks only through explicit, bounded, human-readable delegation objects that humans can approve, cancel, and audit. + +This change should be designed alongside `add-relay-approval-gates`. Delegation approval answers “may this agent ask that agent to do this bounded task?” Approval gates answer “may this session execute this sensitive tool/action now?” They are related but not identical. + +## Goals / Non-Goals + +**Goals:** + +- Let trusted agents publish visible delegation tasks into shared messenger rooms. +- Let target machine bots claim, decline, update, complete, fail, or cancel those tasks. +- Keep humans in supervision control through explicit approvals, cancellations, status, and audit. +- Support targeted delegation (`server`) and capability-oriented delegation (`linux-tests`) with conservative defaults. +- Inject claimed work into the target local Pi session as a transparent prompt with task id, goal, constraints, and report destination. +- Integrate with approval-gate semantics, including task-scoped grants for repeated safe operations within one delegated task. +- Prevent loops, duplicate claims, stale task execution, and accidental prompt injection from bot-authored ordinary output. +- Work through messenger-native surfaces: Discord/Slack threads and buttons when available, Telegram compact cards/replies/buttons when available, text commands everywhere. + +**Non-Goals:** + +- Do not build free-form bot-to-bot chat where arbitrary bot output becomes another bot's prompt. +- Do not build a hidden cross-broker RPC layer for delegation in the first release. +- Do not allow untrusted bots or stale/revoked bindings to create, claim, or approve tasks. +- Do not auto-approve sensitive tool calls just because a task was claimed. +- Do not require full autonomy. Conservative propose/claim flows are acceptable before auto-claim policies mature. +- Do not replace human-directed shared-room commands such as `/use`, `/to`, and `/sessions`. + +## Decisions + +1. **Delegation is a visible task object, not free-form bot chat.** + - Agents create normalized task cards through explicit delegation commands or structured events. + - Other bots act only on validated task objects, not arbitrary bot-authored text. + - Alternative considered: parse natural-language bot messages for intents. Rejected because it is loop-prone and impossible to audit reliably. + +2. **Task cards are human-readable first and machine-parseable second.** + - Cards show task id, source machine/session, target machine or capability, goal, status, expiry, and safe action commands/buttons. + - Persisted task state stores non-secret identifiers, status, room/thread references, trust scope, parent task id, and bounded audit events. + - Raw prompts, hidden context, tool inputs, full transcripts, tokens, and file bytes are not embedded in cards or task state. + +3. **Peer bot trust is separate from human allow-lists.** + - Human allow-lists authorize people to control PiRelay. Peer trust authorizes another bot identity to request or claim delegated work. + - A bot may be trusted for creation, claiming, or both, and may be constrained by room, messenger instance, machine id, and capability. + - Alternative considered: reuse `allowUserIds` for bot peers. Rejected because bot delegation carries different risk and needs separate diagnostics/revocation. + +4. **Delegation has a bounded lifecycle.** + - Proposed tasks expire if unclaimed. Claimed/running tasks have their own timeout or heartbeat/update expectations. + - A task can have at most one active claimant unless later designs add explicit fan-out tasks. + - Human cancellation, source cancellation, target decline, route unavailability, binding revocation, and expiry move tasks to terminal or blocked states. + +5. **Claiming a task does not approve every tool call.** + - Claiming authorizes injecting a bounded task prompt into a target session under policy. + - Sensitive tool calls still require approval gates. + - Delegation adds an approval scope that is narrower than session: `task`. Task-scoped grants apply only to matching operations within the delegated task, target session, requester/approver scope, and TTL. + +6. **Autonomy is policy-driven and conservative.** + - Initial autonomy levels should be explicit, for example `off`, `propose-only`, `auto-claim-targeted`, and `auto-claim-safe-capability`. + - Default behavior should require human approval before agent-originated delegation can cause prompt injection, unless configuration explicitly allows targeted low-risk auto-claiming. + - Broadcast capability tasks should be more conservative than exact-machine tasks. + +7. **Messenger threading is used opportunistically.** + - Discord and Slack should keep task discussion and updates in threads when available. + - Telegram should use compact cards and replies/buttons because thread semantics differ. + - Text fallbacks must exist for every state transition. + +8. **Shared-room delegation remains room-visible coordination by default.** + - In no-federation shared-room mode, each broker observes its own bot/app ingress and owns only local execution state. + - A delegated task may be visible to all bots, but only the target or eligible claimant acts. + - Broker federation may later carry task events directly, but this change should not require NAT traversal, hosted services, or shared bot tokens. + +## Delegation Control-Plane Invariants + +The implementation must treat delegation as a guarded control plane layered on top of shared-room authorization, not as a separate early adapter shortcut. + +1. **Shared-room admission is mandatory.** Delegation commands and task-card actions are accepted only in messenger conversations that satisfy the same shared-room opt-in, allow-list, pairing/binding, and active room checks as ordinary room controls for that platform. +2. **Bot output is inert unless explicitly structured.** Bot-authored messages are dropped before normal prompt routing unless they parse as an explicit delegation command or task-card action and pass peer trust for the exact action. +3. **Task room identity is full-fidelity.** Visibility, status, history, mutation, and result delivery are scoped by messenger, instance id, conversation id, and thread/reply id when the platform provides one; conversation ids alone are never globally authoritative. +4. **Peer trust is action-scoped.** A trusted peer may create, claim, or perform future control actions only when that specific action is configured. Human approval/cancel/decline authority is not implied by peer create trust. +5. **Claim and prompt execution are atomic.** A task is not moved to claimed/running unless execution is allowed and the target prompt is accepted or safely queued with an unambiguous task association. If human supervision is still required, the task remains awaiting approval or claimable. +6. **One active delegated turn per session by default.** Until queued turns carry their own task ids, runtimes must reject or block overlapping delegated claims for a session that already has active delegated work. +7. **Runtime state loss is explicit.** Startup marks unsafe claimed/running work stale or blocked before accepting new delegation actions, and running-timeout policy is enforced so tasks cannot stay active forever. +8. **Messenger redelivery is idempotent.** Task creation and task mutations use persisted event/action keys so retries or polling redelivery do not duplicate tasks or repeat state transitions. +9. **Approval gates remain separate.** Task approval permits bounded delegation flow; sensitive tool execution still depends on the approval-gate control plane and task-scoped grant matching. + +## Risks / Trade-offs + +- **Bot loops and noisy rooms** → Only validated task cards/commands are actionable, ordinary bot output is inert, parent task ids and max depth are enforced, self-authored cards are ignored. +- **Over-permissive peer trust** → Keep peer trust separate from human authorization, default to no peer delegation, require explicit room/machine/capability constraints, and add doctor diagnostics. +- **Approval fatigue** → Use task-scoped approval grants for repeated matching operations, but keep persistent grants disabled by default. +- **Task state drift from messenger history** → Persist bounded local task state and treat message cards as UI, not the source of truth; expired/stale cards cannot be claimed. +- **Duplicate claims across bots** → Use task status transitions with compare-and-set/lock semantics locally; in no-federation mode rely on visible claim messages plus local idempotency and reject later duplicate claims observed by the same broker. +- **Telegram limitations** → Keep Telegram task cards compact and rely on addressed commands/replies; do not promise Discord-like threads. +- **Capability matching ambiguity** → Prefer exact machine targets first; capability broadcasts require policy and may ask for human confirmation when multiple local sessions match. + +## Migration Plan + +1. Add pure delegation domain helpers and task state schema with backward-compatible optional fields. +2. Add configuration and diagnostics for peer trust, capabilities, autonomy, expiry, and max delegation depth. +3. Add command parsing and renderer text for task cards and lifecycle transitions. +4. Add messenger runtime handling in conservative manual/propose mode first. +5. Add target-session prompt injection and terminal update reporting. +6. Integrate task-scoped approval grants with approval-gate implementation when that change is available. +7. Expand to safe auto-claim policies after manual claim/approval behavior is tested. + +## Open Questions + +- Should the first implementation require human approval before every agent-originated task claim, or allow exact-machine low-risk auto-claiming from trusted peers? +- Should task ids be room-local short ids, globally unique ids, or both? +- Should task cards be edited in place where platforms support it, or should updates always be appended as room/thread messages? +- How should a source agent receive final structured results: only as room-visible text, or also injected back into the source session as a follow-up? +- Should delegation capabilities be manually configured only, or can sessions advertise capabilities inferred from local environment and skills? diff --git a/openspec/changes/add-shared-room-agent-delegation/proposal.md b/openspec/changes/add-shared-room-agent-delegation/proposal.md new file mode 100644 index 0000000..1037eca --- /dev/null +++ b/openspec/changes/add-shared-room-agent-delegation/proposal.md @@ -0,0 +1,32 @@ +## Why + +PiRelay shared rooms can now host multiple machine bots safely, but they are still mostly human-commanded control surfaces. The next step is agent-directed delegation: one Pi agent should be able to ask another machine bot to take a bounded task in the same conventional messenger room while humans can supervise, approve, cancel, and understand what is happening. + +## What Changes + +- Add visible, human-readable delegation task cards in shared messenger rooms. +- Add a delegation lifecycle: propose, approve if required, claim, run, report blocked/needs-approval, complete, fail, decline, cancel, and expire. +- Add trusted peer-bot identity and capability policies separate from human allow-lists. +- Allow targeted machine delegation and capability-based delegation with conservative auto-claim defaults. +- Inject claimed delegation work into the target local Pi session as an explicit, bounded prompt with task context and report destination. +- Add loop-prevention rules for bot-authored messages, delegation chains, duplicate claims, and completion feedback. +- Integrate with approval-gate semantics by adding task-scoped approval grants and requiring human supervision for sensitive delegated work. +- Preserve messenger-native behavior: Discord/Slack threads and buttons where available, Telegram compact cards and addressed commands where needed, plain text fallbacks everywhere. + +## Capabilities + +### New Capabilities +- `relay-agent-delegation`: Defines visible shared-room delegation tasks, peer trust, capability matching, task lifecycle, human supervision, approval integration, loop prevention, and messenger rendering. + +### Modified Capabilities +- `shared-room-machine-bots`: Adds rules for bot-authored delegation events, trusted peer bots, task-card targeting, and non-target silence. +- `messenger-relay-sessions`: Adds delegated prompt injection and delegated completion/failure reporting semantics to existing session routing behavior. +- `relay-configuration`: Adds delegation policy configuration for peer trust, capabilities, autonomy level, expiry, depth, and approval requirements. +- `relay-broker-topology`: Clarifies that shared-room delegation remains messenger-visible coordination unless explicit broker federation is configured, and that local brokers own only their local delegation execution state. + +## Impact + +- Affected code: shared-room routing helpers, messenger runtimes, broker route registry/runtime bridge, state store, command surfaces, approval-gate integration, setup/doctor diagnostics, docs, and tests. +- Affected user workflows: multi-machine Discord/Slack/Telegram rooms where agents delegate work to each other under human supervision. +- Security impact: introduces new bot-authored ingress paths, so peer identity, explicit structure, authorization, shared-room pairing, full room/thread scoping, expiry, loop prevention, redelivery idempotency, revocation, approval checks, and audit must be first-class from the start. Delegation is a guarded control-plane path, not general bot chat: bot-authored ordinary messages remain inert, and only explicit scoped delegation commands or task-card actions may create, mutate, claim, or execute tasks. +- No breaking changes to existing human-directed commands, private chat pairing, shared-room active selection, or messenger file delivery. diff --git a/openspec/changes/add-shared-room-agent-delegation/specs/messenger-relay-sessions/spec.md b/openspec/changes/add-shared-room-agent-delegation/specs/messenger-relay-sessions/spec.md new file mode 100644 index 0000000..e373d84 --- /dev/null +++ b/openspec/changes/add-shared-room-agent-delegation/specs/messenger-relay-sessions/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Delegated prompt delivery +Messenger relay sessions SHALL support prompt delivery that originates from a claimed shared-room delegation task while preserving existing authorization, route safety, and output scoping rules. + +#### Scenario: Delegated task prompt is handed to target session +- **WHEN** a delegation task is claimed for an online local session and policy allows execution +- **THEN** PiRelay injects a bounded task prompt into that session using the same route-action safety rules as ordinary remote prompts +- **AND** the prompt identifies the task id, source machine/session, goal, constraints, and report destination + +#### Scenario: Delegated task prompt cannot be delivered +- **WHEN** the selected target session is offline, stale, paused, revoked, unavailable, or ambiguous before prompt handoff +- **THEN** PiRelay does not acknowledge successful task start +- **AND** it marks or reports the task as blocked, failed, or needing human intervention according to policy + +#### Scenario: Delegated output is sent to task room +- **WHEN** a delegated task completes, fails, is aborted, or is blocked for approval +- **THEN** PiRelay sends a bounded task update to the originating shared room or thread through the target machine bot identity +- **AND** it does not also send delegated completion, progress, media, or guided-action output to unrelated paired private chats or active selections for the same route +- **AND** non-target machine bots do not send completion, progress, media, or guided-action output for that task + +### Requirement: Delegation task controls +Messenger relay sessions SHALL expose task controls through platform-appropriate commands, buttons, or text fallbacks without weakening normal remote command authorization. + +#### Scenario: Authorized human cancels task +- **WHEN** an authorized human sends a task cancel command or uses a task cancel action for a pending, claimed, running, or blocked task +- **THEN** PiRelay cancels the task if the human is authorized for the task room and machine scope +- **AND** it rejects future claim/update actions for that task id + +#### Scenario: Unauthorized user invokes task action +- **WHEN** an unauthorized user invokes claim, approve, decline, cancel, or status for a delegation task +- **THEN** PiRelay rejects the action before prompt injection, media download, route mutation, approval resolution, or task-state mutation + +#### Scenario: Delegation command arrives outside paired room boundary +- **WHEN** a user or peer bot sends a delegation command in a group/channel that is not enabled, paired, or selected as a shared-room control surface for that messenger instance +- **THEN** PiRelay rejects or ignores the command before task creation, task mutation, prompt injection, callback handling, or media download + +#### Scenario: Telegram human delegation requires private pairing +- **WHEN** a non-bot Telegram user addresses the bot with a delegation command in a group where that user has not completed the private pairing/session setup required for other group controls +- **THEN** PiRelay rejects or guides setup before task creation, task mutation, or prompt injection +- **AND** this human pairing boundary does not prevent explicitly configured trusted Telegram peer bots from being evaluated through peer-trust policy + +#### Scenario: Task status is requested +- **WHEN** an authorized user requests status for a delegation task visible in the current room or thread +- **THEN** PiRelay returns bounded task state including id, source, target, status, claimant when non-secret, expiry, and latest safe update + +#### Scenario: Text fallbacks use executable commands +- **WHEN** PiRelay renders delegation task action text fallbacks for Slack, Discord, or Telegram +- **THEN** the fallback commands match that platform's currently parsed command surface +- **AND** unsupported slash-command syntax is not advertised as the only way to perform a task action diff --git a/openspec/changes/add-shared-room-agent-delegation/specs/relay-agent-delegation/spec.md b/openspec/changes/add-shared-room-agent-delegation/specs/relay-agent-delegation/spec.md new file mode 100644 index 0000000..8b09e83 --- /dev/null +++ b/openspec/changes/add-shared-room-agent-delegation/specs/relay-agent-delegation/spec.md @@ -0,0 +1,181 @@ +## ADDED Requirements + +### Requirement: Visible delegation task cards +PiRelay SHALL represent agent-directed shared-room work as visible delegation task cards that are human-readable, bounded, and machine-parseable. + +#### Scenario: Agent creates delegation task +- **WHEN** a trusted agent or authorized human creates a delegation task in an authorized shared messenger room +- **THEN** PiRelay creates a task with a short user-visible id, source machine/session label, target machine or capability, goal summary, status, expiry, and full room/thread reference containing messenger, instance id, conversation id, and thread/reply id when available +- **AND** it renders a task card in the originating room using platform-appropriate text, buttons, or thread affordances + +#### Scenario: Task card excludes sensitive data +- **WHEN** PiRelay renders or persists a delegation task card +- **THEN** it excludes bot tokens, pairing codes, hidden prompts, full transcripts, raw tool inputs, file bytes, upload URLs, and internal session storage keys +- **AND** it bounds and redacts user-provided goal/context fields before display + +#### Scenario: Ordinary bot output is inert +- **WHEN** a machine bot posts ordinary completion, status, or explanatory output in a shared room +- **THEN** other PiRelay bots SHALL NOT treat that output as a delegation task or prompt unless it is an explicit validated delegation command or task-card action + +### Requirement: Delegation task lifecycle +PiRelay SHALL manage each delegation task through a bounded lifecycle with single-claim execution semantics. + +#### Scenario: Task is proposed and claimable +- **WHEN** a delegation task passes creator trust, room authorization, target, expiry, and policy checks +- **THEN** PiRelay marks the task as proposed or claimable and exposes safe claim, decline, cancel, and status actions where the platform supports them + +#### Scenario: Target claims task +- **WHEN** a trusted target machine bot or authorized human claims an unexpired claimable task for an eligible local session and policy permits execution now +- **THEN** PiRelay transitions the task to claimed/running for that claimant only after the target prompt handoff is accepted or safely queued with an unambiguous task association +- **AND** prevents later duplicate claims from being accepted for the same task unless the task has been explicitly released or failed back to claimable +- **AND** rejects or blocks the claim when the target session already has active delegated work and queued turns cannot preserve a task id per turn + +#### Scenario: Concurrent lifecycle mutations are serialized +- **WHEN** two claim, approve, cancel, decline, start, or terminal updates race for the same stored delegation task +- **THEN** PiRelay applies each lifecycle transition against the current stored task state inside the serialized state update or rejects the stale write with safe guidance +- **AND** it does not overwrite a claimed, terminal, or otherwise newer task state with a transition computed from an older snapshot + +#### Scenario: Task completes +- **WHEN** the target session completes delegated work with a final result +- **THEN** PiRelay marks the task completed and reports a bounded result summary to the task room or thread through the target machine bot identity + +#### Scenario: Task fails or is blocked +- **WHEN** delegated work fails, route state becomes unavailable, required approval is denied, or the target reports it cannot proceed +- **THEN** PiRelay marks the task failed or blocked with a safe reason +- **AND** it does not claim successful completion or inject follow-up prompts into unrelated sessions + +#### Scenario: Task expires or is cancelled +- **WHEN** an unclaimed task expires, a running task exceeds configured timeout, a human cancels it, the source/target binding is revoked, or the owning route unregisters +- **THEN** PiRelay marks the task expired or cancelled and rejects future claim/update/approval actions for that task id + +### Requirement: Peer trust and capability matching +PiRelay SHALL authorize delegated task creation and claiming using peer-bot trust and capability policy separate from human allow-lists. + +#### Scenario: Trusted peer creates task +- **WHEN** a bot-authored delegation request arrives from a configured trusted peer identity in an authorized shared room +- **THEN** PiRelay may create a task according to that peer's configured creation scope, allowed rooms, target machines, capabilities, and autonomy policy +- **AND** that creation trust does not authorize claim, approve, cancel, decline, or other task-control actions unless those actions are separately configured + +#### Scenario: Untrusted peer creates task +- **WHEN** a bot-authored delegation request arrives from an unknown, untrusted, revoked, or disallowed bot identity +- **THEN** PiRelay ignores or rejects the request before task creation, prompt injection, media download, callback execution, or state mutation + +#### Scenario: Capability target is matched +- **WHEN** a task targets a capability instead of a specific machine +- **THEN** PiRelay may treat a local machine/session as eligible only if configuration declares that capability for the local machine or session +- **AND** ambiguity or multiple matching sessions requires safe disambiguation or human approval rather than guessing + +#### Scenario: Human allow-list does not imply peer trust +- **WHEN** a bot identity appears in human allow-list configuration but is not configured as a trusted delegation peer +- **THEN** PiRelay SHALL NOT accept bot-authored delegation creation or claiming from that identity solely because it is allow-listed as a human controller + +#### Scenario: Peer bot attempts human-only control +- **WHEN** a peer bot invokes approve, cancel, decline, or another task-control action without explicit peer control permission +- **THEN** PiRelay rejects or ignores the action before task-state mutation, prompt injection, callback handling, media download, or approval resolution + +### Requirement: Human supervision and autonomy levels +PiRelay SHALL gate autonomous delegation behavior through explicit local policy and human supervision options. + +#### Scenario: Delegation autonomy is off +- **WHEN** delegation autonomy is disabled for a messenger instance or room +- **THEN** PiRelay does not create, claim, or inject work from bot-authored delegation requests +- **AND** existing human-directed shared-room commands continue to work unchanged + +#### Scenario: Propose-only mode is enabled +- **WHEN** a trusted peer creates a delegation task under propose-only policy +- **THEN** PiRelay renders the task card for human review but does not inject the task into any target session until an authorized human approves or claims it +- **AND** claim attempts that still require human supervision do not move the task to claimed/running or remove it from circulation + +#### Scenario: Targeted auto-claim is enabled +- **WHEN** a trusted peer creates an unexpired task explicitly targeting the local machine and local policy allows targeted auto-claiming for that peer/capability +- **THEN** PiRelay may claim the task automatically for an eligible local session +- **AND** sensitive tool calls inside the task still require approval according to approval-gate policy + +#### Scenario: Human cancels task +- **WHEN** an authorized human invokes cancel for a pending, claimed, running, or blocked delegation task +- **THEN** PiRelay marks the task cancelled, stops accepting further task actions, and requests cancellation of any associated local prompt or operation when possible + +### Requirement: Delegated prompt injection +PiRelay SHALL inject claimed delegated work into the target local Pi session as a transparent bounded prompt with task context. + +#### Scenario: Claimed task starts target session work +- **WHEN** a task is claimed for an online eligible local session +- **THEN** PiRelay sends that session a prompt containing the task id, source machine/session, goal, constraints, report destination, and instruction to summarize results back to the room +- **AND** the prompt does not include hidden source prompts, full transcripts, secrets, or unbounded context + +#### Scenario: Target session is unavailable +- **WHEN** a task claim targets a session that is offline, stale, paused, unavailable, or revoked before prompt handoff +- **THEN** PiRelay does not report successful claim or delivery +- **AND** it marks the task blocked, failed, or still claimable according to policy with safe guidance + +#### Scenario: Prompt handoff fails after claim persistence +- **WHEN** PiRelay has persisted a delegation claim but prompt handoff fails before the target session accepts or commits the prompt +- **THEN** PiRelay transitions the task to blocked or failed with a safe reason and renders a task update +- **AND** the task is not left indefinitely claimed without active delegated work + +#### Scenario: Source receives result visibility +- **WHEN** a delegated task completes or fails +- **THEN** PiRelay reports the result to the originating shared room or thread +- **AND** it MAY provide a bounded follow-up to the source session only when configured and when source route/requester state is still active and authorized + +### Requirement: Delegation approval integration +PiRelay SHALL integrate delegated work with approval-gate semantics without treating task claim as blanket approval for sensitive operations. + +#### Scenario: Delegation creation requires approval +- **WHEN** policy classifies a proposed delegation as requiring human approval before another agent may claim or run it +- **THEN** PiRelay marks the task awaiting approval and does not inject it into a target session until an authorized approval decision is recorded + +#### Scenario: Task-scoped approval grant is used +- **WHEN** an authorized approver chooses approve-for-task for a sensitive operation inside a delegated task +- **THEN** PiRelay creates a grant scoped to that task id, target session, requester/binding or approver scope, matcher fingerprint, and expiry +- **AND** future matching operations within that same task may proceed while the grant remains active + +#### Scenario: Task-scoped grant does not escape task +- **WHEN** a later operation matches the same tool/category pattern but belongs to a different task, different session, different requester scope, expired task, or revoked binding +- **THEN** PiRelay does not use the task-scoped grant and requires a fresh approval when policy requires one + +#### Scenario: Persistent grants are not implied by delegation +- **WHEN** a human approves a delegation task or task-scoped operation +- **THEN** PiRelay does not create persistent or cross-session approval grants unless persistent grants are explicitly enabled by local configuration and explicitly selected by an authorized approver + +#### Scenario: Persistent approval option is explicitly enabled +- **WHEN** local configuration enables persistent approval grants for an approval surface +- **THEN** PiRelay may render a distinct persistent approval option alongside approve-once, task, session, and deny options as applicable +- **AND** the persistent option is not shown when persistent grants are not explicitly enabled + +### Requirement: Delegation loop prevention +PiRelay SHALL prevent delegation loops, self-triggering, and unbounded delegation chains. + +#### Scenario: Bot sees its own task card +- **WHEN** a messenger platform redelivers a task card or task update authored by the local bot identity +- **THEN** PiRelay ignores it for task creation, claim, prompt injection, and follow-up delegation + +#### Scenario: Delegation depth is exceeded +- **WHEN** an agent attempts to create a child delegation whose parent chain exceeds configured maximum delegation depth +- **THEN** PiRelay rejects or requires human approval for the child delegation and does not auto-claim it + +#### Scenario: Completion summary resembles task request +- **WHEN** a delegated task completion or failure summary contains text that looks like an instruction or request +- **THEN** PiRelay treats it as inert output unless it is accompanied by a validated explicit delegation command or action + +#### Scenario: Duplicate delivery is observed +- **WHEN** Slack retries an event, Discord redelivers an interaction, Telegram update polling sees duplicate updates, or history fallback observes an already-handled task event +- **THEN** PiRelay handles task creation, claim, cancellation, approval, and result reporting at most once for the persisted task event id or task id/action pair + +#### Scenario: Expired tasks are normalized on read +- **WHEN** task status, task history, or a runtime lookup reads a proposed, awaiting-approval, claimable, claimed, or running task after its configured expiry or running timeout +- **THEN** PiRelay returns and persists an expired terminal state before rendering the task or accepting further task actions +- **AND** stale task cards no longer advertise expired tasks as claimable or awaiting approval + +### Requirement: Delegation audit and history +PiRelay SHALL record bounded non-secret audit events for delegation task lifecycle and supervision decisions. + +#### Scenario: Task lifecycle changes +- **WHEN** a delegation task is created, approved, claimed, started, blocked, completed, failed, declined, cancelled, expired, or rejected +- **THEN** PiRelay records a bounded audit event with task id, safe source/target machine identity, status, actor identity, timestamp, and safe reason or summary +- **AND** it excludes secrets, hidden prompts, full transcripts, file bytes, raw tool input, and internal callback payloads + +#### Scenario: Authorized user requests task history +- **WHEN** an authorized user requests delegation task history for a room, machine, or session +- **THEN** PiRelay returns a bounded list of recent task cards or lifecycle summaries scoped to that user's authorized messenger context +- **AND** room history filtering uses the full room reference, including messenger, instance id, conversation id, and thread/reply id when available diff --git a/openspec/changes/add-shared-room-agent-delegation/specs/relay-broker-topology/spec.md b/openspec/changes/add-shared-room-agent-delegation/specs/relay-broker-topology/spec.md new file mode 100644 index 0000000..274c2df --- /dev/null +++ b/openspec/changes/add-shared-room-agent-delegation/specs/relay-broker-topology/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Delegation broker ownership boundaries +Broker topology SHALL keep shared-room delegation execution state local to the machine that claims and runs the delegated task unless explicit broker federation support is configured. + +#### Scenario: No-federation shared-room delegation +- **WHEN** multiple independent machine brokers observe the same shared-room delegation task through distinct bot/app identities +- **THEN** each broker evaluates only its own eligibility, peer trust, and local session state +- **AND** no broker assumes remote route ownership, forwards prompts directly to another broker, or invents remote task state outside visible room coordination + +#### Scenario: Local broker claims task +- **WHEN** a local broker claims a delegation task for one of its local sessions +- **THEN** that broker owns the target execution state, route-action safety checks, local task audit, terminal update reporting, and task-scoped approval state for that claim + +#### Scenario: Broker restarts with running delegation task +- **WHEN** a broker or messenger runtime starts while a delegation task is pending, claimed, running, blocked, or recently completed +- **THEN** it reloads bounded non-secret local task state when available and marks unsafe in-flight work stale, blocked, or unavailable before accepting new delegation actions instead of silently continuing with stale route references + +#### Scenario: Federation is configured in future +- **WHEN** explicit broker federation is configured to carry delegation task events directly between brokers +- **THEN** federation messages must preserve the same task identity, peer trust, authorization, expiry, approval, and loop-prevention rules as messenger-visible coordination +- **AND** they must not send bot tokens, hidden prompts, full transcripts, raw tool inputs, or file bytes unless a separate safe file-transfer capability explicitly allows it diff --git a/openspec/changes/add-shared-room-agent-delegation/specs/relay-configuration/spec.md b/openspec/changes/add-shared-room-agent-delegation/specs/relay-configuration/spec.md new file mode 100644 index 0000000..96ff3a6 --- /dev/null +++ b/openspec/changes/add-shared-room-agent-delegation/specs/relay-configuration/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Agent delegation configuration +The system SHALL provide explicit configuration for shared-room agent delegation without enabling autonomous peer-bot work by default. + +#### Scenario: Delegation is disabled by default +- **WHEN** PiRelay configuration does not enable agent delegation for a messenger instance or shared room +- **THEN** bot-authored delegation creation, claim, and auto-execution are disabled +- **AND** existing human-directed shared-room commands remain unchanged + +#### Scenario: Trusted peer bot is configured +- **WHEN** configuration declares a trusted peer bot identity for delegation +- **THEN** it records only non-secret peer identity metadata, allowed messenger instance/room scope, allowed source/target machines, capabilities, creation/claim permissions, and optional display label +- **AND** diagnostics do not print tokens, pairing codes, hidden prompts, transcripts, or raw task prompt content + +#### Scenario: Local capabilities are configured +- **WHEN** configuration declares local machine or session capabilities such as `linux-tests`, `browser`, or `long-running-jobs` +- **THEN** PiRelay uses those non-secret capabilities for delegation eligibility and diagnostics +- **AND** it does not infer broad capabilities from installed tools unless explicitly configured or safely detected by a future opt-in mechanism + +#### Scenario: Autonomy level is configured +- **WHEN** configuration sets delegation autonomy to `off`, `propose-only`, `auto-claim-targeted`, or `auto-claim-safe-capability` +- **THEN** PiRelay applies that level as an upper bound on bot-authored task behavior for the configured messenger instance or room only when delegation is also explicitly enabled +- **AND** unknown autonomy values are rejected by config validation or reported by doctor diagnostics +- **AND** setting a non-`off` autonomy value without `enabled: true` does not by itself enable delegation + +#### Scenario: Delegation bounds are configured +- **WHEN** configuration enables delegation +- **THEN** PiRelay applies bounded defaults or configured values for task expiry, running timeout, maximum delegation depth, maximum visible summary length, and maximum recent task history + +### Requirement: Delegation diagnostics +The system SHALL report shared-room delegation readiness and unsafe configuration without exposing secrets. + +#### Scenario: Doctor reports delegation readiness +- **WHEN** the local user invokes `/relay doctor` with delegation enabled +- **THEN** diagnostics report delegation autonomy level, trusted peer count, local capability labels, room/channel readiness, approval-gate dependency status, and platform limitations +- **AND** diagnostics do not print bot tokens, task hidden prompts, full transcripts, pairing codes, or raw tool inputs + +#### Scenario: Delegation configuration is unsafe +- **WHEN** delegation is enabled without explicit peer trust, room scope, known local bot identity, human approval path, or required shared-room platform readiness +- **THEN** diagnostics report a warning or blocking finding and recommend propose-only or disabled mode until the unsafe condition is resolved diff --git a/openspec/changes/add-shared-room-agent-delegation/specs/shared-room-machine-bots/spec.md b/openspec/changes/add-shared-room-agent-delegation/specs/shared-room-machine-bots/spec.md new file mode 100644 index 0000000..41d52fc --- /dev/null +++ b/openspec/changes/add-shared-room-agent-delegation/specs/shared-room-machine-bots/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Shared-room delegation task routing +Shared-room machine bots SHALL treat bot-authored delegation events as actionable only when they are validated task objects or explicit task actions from trusted peers. + +#### Scenario: Trusted peer task targets local machine +- **WHEN** a trusted peer bot publishes a validated delegation task in an opted-in and paired shared room and the task explicitly targets the local machine bot +- **THEN** the local broker may evaluate the task for approval, claim, or manual human review according to local delegation policy +- **AND** non-target brokers remain silent except for their own eligible task-card observation state + +#### Scenario: Trusted peer task targets another machine +- **WHEN** a trusted peer bot publishes a validated delegation task that clearly targets another machine bot +- **THEN** the local broker does not claim, inject, acknowledge, or mutate local session state for that task + +#### Scenario: Capability task is visible to multiple machines +- **WHEN** a validated delegation task targets a capability rather than one exact machine +- **THEN** each broker may consider the task only if local policy declares that capability and the peer/room is trusted +- **AND** claim behavior remains single-target and conservative according to delegation policy + +#### Scenario: Capability task creation requires explicit source scoping +- **WHEN** a human or peer publishes a capability-target delegation creation command in a room observed by multiple machine bots +- **THEN** PiRelay creates the visible task only when the command is explicitly scoped to the local source broker, such as by addressing the local bot identity +- **AND** unaddressed capability creation commands do not cause each observing bot to render duplicate task cards + +#### Scenario: Free-form bot-authored text is ignored +- **WHEN** a machine bot observes bot-authored text that is not a validated delegation task, validated task action, or existing supported bot-to-bot command +- **THEN** the broker treats the message as inert shared-room output and does not inject it as a prompt + +#### Scenario: Bot-authored delegation text outside validated shared rooms is ignored +- **WHEN** a bot-authored message resembles a delegation command in a private chat, unpaired room, disabled shared room, or another conversation where the runtime will not validate shared-room delegation +- **THEN** PiRelay drops the message before normal prompt routing +- **AND** bot-authored delegation-like text is not delivered to a paired session as an ordinary user prompt + +### Requirement: Delegation safe silence and loop prevention +Shared-room machine bots SHALL preserve safe silence and loop-prevention invariants for delegation tasks. + +#### Scenario: Local bot observes own delegation output +- **WHEN** the local machine bot observes a task card, task update, or result message authored by itself +- **THEN** it ignores that event for task creation, claim, prompt injection, and task follow-up generation + +#### Scenario: Delegation task is stale +- **WHEN** a task card or task action references an expired, completed, cancelled, revoked, or unknown task +- **THEN** non-target brokers remain silent and any addressed local broker returns only safe stale-task guidance + +#### Scenario: Delegation chain is too deep +- **WHEN** a shared-room task attempts to create a child task beyond configured delegation depth +- **THEN** the local broker rejects auto-claim behavior and requires human supervision or rejects the child task according to policy diff --git a/openspec/changes/add-shared-room-agent-delegation/tasks.md b/openspec/changes/add-shared-room-agent-delegation/tasks.md new file mode 100644 index 0000000..d57e1fe --- /dev/null +++ b/openspec/changes/add-shared-room-agent-delegation/tasks.md @@ -0,0 +1,83 @@ +## 1. Delegation Domain Model + +- [x] 1.1 Add pure delegation task types, status enums, lifecycle transition helpers, and validation helpers under shared relay core. +- [x] 1.2 Add task id generation, expiry checks, parent/depth tracking, and duplicate event/action idempotency helpers. +- [x] 1.3 Add safe task-card summary formatting and redaction helpers with bounded field lengths. +- [x] 1.4 Add unit tests for lifecycle transitions, stale actions, expiry, duplicate claims, and loop-prevention helpers. + +## 2. State and Configuration + +- [x] 2.1 Extend persisted state with backward-compatible optional delegation task records and bounded audit history. +- [x] 2.2 Add config schema/loading for delegation enablement, autonomy level, trusted peers, room scope, capabilities, expiry, timeout, and max depth. +- [x] 2.3 Add doctor/setup diagnostics for delegation readiness, unsafe autonomy, missing peer trust, unknown bot identity, and platform limitations. +- [x] 2.4 Add tests for config parsing, state migration/backward compatibility, diagnostics, and secret redaction. + +## 3. Command and Card Rendering + +- [x] 3.1 Add messenger-neutral parsing for task commands such as delegate/propose, claim, decline, cancel, status, and history. +- [x] 3.2 Add shared task-card renderers for proposed, awaiting approval, claimed, running, blocked, completed, failed, declined, cancelled, and expired states. +- [x] 3.3 Add platform affordance mapping for buttons/components/Block Kit/Telegram inline keyboards where available and text fallbacks everywhere. +- [x] 3.4 Add command-surface/help/docs updates for delegation commands without advertising unsupported platform behavior. +- [x] 3.5 Add tests for card rendering, action ids, text fallbacks, and command parser ambiguity. + +## 4. Shared-Room Runtime Routing + +- [x] 4.1 Add shared-room pre-routing for validated delegation task creation/actions while keeping ordinary bot-authored output inert. +- [x] 4.2 Implement trusted peer checks separate from human allow-lists for Telegram, Discord, and Slack inbound task events/actions. +- [x] 4.3 Implement target/capability eligibility checks, exact-machine targeting, safe silence for non-target brokers, and human disambiguation for ambiguous local targets. +- [x] 4.4 Add platform-specific handling for Discord threads/buttons, Slack threads/Block Kit/response URLs, and Telegram compact cards/replies/inline buttons. +- [x] 4.5 Add runtime tests for trusted peer creation, untrusted peer rejection, local/remote target silence, capability matching, duplicate delivery, and stale task actions. + +## 5. Delegated Prompt Execution + +- [x] 5.1 Implement claim-to-prompt handoff that injects bounded task context into the selected local route using existing route-action safety helpers. +- [x] 5.2 Report successful claim/start only after the target route accepts the prompt handoff. +- [x] 5.3 Report delegated completion, failure, abort, blocked, and unavailable states to the originating room/thread through the target machine bot identity. +- [x] 5.4 Ensure source-session follow-up injection is disabled by default or gated by explicit config and active authorization checks. +- [x] 5.5 Add tests for route unavailable races, revoked bindings, paused sessions, output scoping, and terminal task reporting. + +## 6. Approval-Gate Integration + +- [x] 6.1 Coordinate with `add-relay-approval-gates` implementation and add task id/context to approval request targeting when delegated work is active. +- [x] 6.2 Add task-scoped approval grant semantics that are narrower than session grants and expire with task/session/binding lifecycle. +- [x] 6.3 Add approval UI options for approve once, approve for task, approve for session when enabled, and deny. +- [x] 6.4 Add tests proving task approval does not escape to other tasks, sessions, requesters, revoked bindings, or persistent grants. + +## 7. Loop Prevention and Autonomy Policies + +- [x] 7.1 Enforce autonomy levels `off`, `propose-only`, `auto-claim-targeted`, and `auto-claim-safe-capability` as upper bounds on bot-authored task behavior. +- [x] 7.2 Enforce maximum delegation depth and parent task tracking for child delegations. +- [x] 7.3 Ignore self-authored task cards, ordinary bot output, completion summaries, and malformed bot-authored delegation-like text. +- [x] 7.4 Add tests for feedback-loop prevention, delegation depth, auto-claim policy boundaries, and cancellation behavior. + +## 8. Broker and Documentation + +- [x] 8.1 Keep no-federation delegation state local to the claimant broker and document that shared-room messages are the coordination medium. +- [x] 8.2 Add broker restart/stale route handling for pending/running delegation tasks. +- [x] 8.3 Update README, docs/adapters.md, docs/config.md, docs/testing.md, and shared-room parity docs with delegation setup and smoke checks. +- [x] 8.4 Add optional live/manual smoke checklist for two or more machine bots delegating in Discord/Slack/Telegram shared rooms. + +## 9. Delegation Control-Plane Hardening + +- [x] 9.1 Centralize delegation admission checks so Telegram, Discord, and Slack require explicit shared-room opt-in, authorization, and pairing/binding before task handling. +- [x] 9.2 Make bot-authored non-delegation messages inert before normal prompt routing in all adapters/runtimes. +- [x] 9.3 Scope task lookup, listing, history, mutation, and result delivery by full room ref: messenger, instance id, conversation id, and thread/reply id when available. +- [x] 9.4 Enforce action-scoped peer trust so create-only peers cannot claim, approve, cancel, decline, or otherwise control tasks. +- [x] 9.5 Prevent claim-before-approval and overlapping active delegated tasks for the same session unless queued prompt task ids are implemented. +- [x] 9.6 Persist delegation event/action idempotency keys and apply them to create and mutation paths across messenger redelivery/retry. +- [x] 9.7 Mark unsafe in-flight delegation tasks stale on runtime/broker startup and enforce running timeouts. +- [x] 9.8 Add regression tests for the hardening invariants above across core helpers, state store, and Telegram/Discord/Slack runtimes. +- [x] 9.9 Add compare-and-set style task persistence so stale lifecycle transitions cannot overwrite newer task state. +- [x] 9.10 Normalize expired pending and running tasks on task lookup/listing before rendering or accepting actions. +- [x] 9.11 Add regression coverage for Telegram human delegation pairing boundaries, Discord bot-output inertness, executable text fallbacks, persistent approval options, and stale mutation conflicts. +- [x] 9.12 Block or fail tasks when prompt handoff fails after claim persistence instead of leaving them claimed. +- [x] 9.13 Require explicit local source-broker addressing for capability-target task creation in shared rooms. +- [x] 9.14 Prevent unauthorized humans or peer-bot non-delegation text from mutating shared-room active-selection state before authorization/inertness checks. + +## 10. Validation + +- [x] 10.1 Run `npm run typecheck`. +- [x] 10.2 Run `npm test`. +- [x] 10.3 Run `npm run openspec:validate`. +- [x] 10.4 Run `openspec validate add-shared-room-agent-delegation --strict`. +- [x] 10.5 Review changed files for unrelated edits, leaked secrets, hidden prompt exposure, and accidental implementation outside this change. diff --git a/openspec/changes/improve-delegation-task-card-ux/.openspec.yaml b/openspec/changes/improve-delegation-task-card-ux/.openspec.yaml new file mode 100644 index 0000000..66da1ae --- /dev/null +++ b/openspec/changes/improve-delegation-task-card-ux/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-17 diff --git a/openspec/changes/improve-delegation-task-card-ux/design.md b/openspec/changes/improve-delegation-task-card-ux/design.md new file mode 100644 index 0000000..b37bbfb --- /dev/null +++ b/openspec/changes/improve-delegation-task-card-ux/design.md @@ -0,0 +1,100 @@ +## Context + +Shared-room delegation now works end-to-end in Slack, but the visible task card is a single plain-text blob combining lifecycle state, participants, goal, expiry, latest result, and fallback commands. That format is useful for logs and tests but hard for humans to scan, especially when several machine bots share one channel. + +The code already exposes the core pieces needed for richer UI: + +- `renderDelegationTaskCard()` returns safe bounded text plus canonical actions. +- `delegationTaskActionsForStatus()` maps task states to claim/approve/decline/cancel/status actions. +- `delegationActionId()` and `parseDelegationActionId()` provide stable callback payloads. +- Slack, Discord, and Telegram adapters already support normalized button layouts. +- Discord delegation already maps task actions to buttons; Slack currently sends only the text task card. + +The design should therefore avoid changing task semantics and instead make presentation explicit, testable, and platform-aware. + +## Goals / Non-Goals + +**Goals:** + +- Create a shared presentation layer for delegation task cards so status, fields, latest result, and actions are represented structurally before platform rendering. +- Render Slack delegation cards using platform-native button actions where supported, with readable fallback text for copied/manual command use. +- Preserve existing text commands (`relay task claim ...`, `relay task status ...`) for platforms without callbacks, stale cards, debugging, and accessibility. +- Make lifecycle states visually distinct: claimable/awaiting approval, claimed/running, completed, blocked/failed/cancelled/expired. +- Preserve all authorization, task-state, and shared-room silence semantics; UI improvements must not authorize new actions. +- Add unit tests for presentation mapping, Slack/adapter button rendering, fallback text, and stale/non-owner callback behavior. + +**Non-Goals:** + +- Updating previous task messages in place by storing Slack timestamps or Telegram message ids. +- Changing delegation lifecycle semantics, autonomy policy, peer trust, or prompt handoff behavior. +- Removing plain-text command handling. +- Introducing new dependencies or a separate hosted service. +- Redesigning unrelated Slack status, progress, or final-output messages. + +## Decisions + +### Decision: Add a shared delegation task presentation model + +Create a messenger-neutral presentation object derived from `DelegationTaskRecord`, for example: + +```ts +interface DelegationTaskPresentation { + title: string; + status: { value: DelegationTaskStatus; label: string; icon: string }; + fields: Array<{ label: string; value: string }>; + latest?: { label: string; value: string }; + actions: DelegationTaskAction[]; + fallbackText: string; + accessibilityText: string; +} +``` + +The exact shape can be adjusted during implementation, but it should keep domain semantics separate from Slack/Telegram/Discord formatting. + +Rationale: duplicating status/field/result selection in each adapter would drift over time. A shared presentation layer gives adapters a consistent source of truth while still allowing platform-specific rendering. + +Alternatives considered: + +- Keep `renderDelegationTaskCard()` as a text-only function and let adapters parse text. Rejected because parsing rendered text is brittle and loses semantic action/status data. +- Implement only a Slack-specific renderer. Rejected because Discord and Telegram already have button support and should share the same action semantics even if their visual polish differs. + +### Decision: Preserve append-only task updates for this change + +Each lifecycle update should continue to send a new task card/update message. Do not store platform message ids for edit-in-place behavior in this change. + +Rationale: append-only updates are simple, auditable, and match current live-test behavior. Updating in place requires storing platform-specific message references, handling race conditions, and deciding how to preserve audit visibility. + +Alternative considered: + +- Update the original Slack task card in place. Deferred as a future change because it requires persisted per-platform message metadata and stale-update conflict handling. + +### Decision: Use existing normalized button layout and callback ids + +Task actions should be rendered as `ChannelButtonLayout` using existing `pirelay:delegation::` action ids. Slack should attach buttons to the task card instead of posting a separate generic `Actions:` message when possible. + +Rationale: callback ids already route through delegation action parsing and preserve authorization checks. Reusing the adapter button contract keeps Telegram, Discord, and Slack aligned. + +Alternative considered: + +- Add Slack-only Block Kit action ids and handlers. Rejected because it forks semantics and makes cross-adapter parity harder. + +### Decision: Fallback commands are secondary but always present or recoverable + +When native buttons are available, the card should not lead with inline fallback commands. It may include compact fallback text in a context/footer section or expose it in tests/accessibility text. When buttons are unavailable, fallback commands become the visible action surface. + +Rationale: users should see clear buttons first, but text commands remain valuable for manual live tests, copy/paste, accessibility, and platforms or contexts where callbacks are unavailable. + +### Decision: Terminal cards must highlight the result summary + +Completed, failed, blocked, cancelled, declined, and expired states should visually emphasize the terminal result/reason rather than burying it among command text. Real-agent live tests should assert that a completed card includes the run marker and latest result. + +Rationale: live testing showed that `Status: running` is only handoff confirmation; users need to see whether work actually completed and what the result was. + +## Risks / Trade-offs + +- **Risk: native buttons trigger duplicate or stale actions** → Keep existing task lookup, authorization, idempotency, and stale-action checks; add tests for unknown/non-owner task ids remaining silent in shared rooms. +- **Risk: richer Slack rendering accidentally removes useful text fallback** → Keep `accessibilityText`/fallback output and test that fallback commands are still generated. +- **Risk: platform-specific markup mentions users/channels unexpectedly** → Reuse existing safe text helpers and adapter escaping/formatting behavior; bound all user-provided fields. +- **Risk: cards become inconsistent across Slack, Discord, and Telegram** → Centralize task status/field/action selection in the presentation model; adapters only map it to platform UI. +- **Risk: append-only cards create channel noise** → Accept for this change; consider edit-in-place as a later scoped change once message reference storage is designed. +- **Risk: adding buttons changes perceived authorization** → Treat buttons as transport for existing commands only; all runtime authorization and state-transition checks remain authoritative. diff --git a/openspec/changes/improve-delegation-task-card-ux/proposal.md b/openspec/changes/improve-delegation-task-card-ux/proposal.md new file mode 100644 index 0000000..8a7bcbc --- /dev/null +++ b/openspec/changes/improve-delegation-task-card-ux/proposal.md @@ -0,0 +1,27 @@ +## Why + +Delegation task cards currently render as dense plain text that is technically human- and machine-readable but difficult to scan during live Slack shared-room workflows. The task lifecycle already exposes canonical actions, and several adapters already support buttons, so PiRelay should present delegation state and actions through platform-native UI with text commands only as a fallback. + +## What Changes + +- Introduce a first-class delegation task presentation model that separates task semantics from messenger-specific rendering. +- Render delegation task state with clear status, participants, goal, expiry, and latest result sections instead of one long text paragraph. +- Expose claim, approve, decline, cancel, and status actions as platform-native buttons when the messenger supports callbacks. +- Preserve safe plain-text fallback commands for platforms or contexts where buttons are unavailable, stale, or unsupported. +- Improve Slack delegation task cards using Block Kit-compatible text/actions while keeping shared-room multi-bot silence and authorization rules intact. +- Add parity-oriented tests for presentation, action rendering, fallback text, and non-target/stale action behavior. + +## Capabilities + +### New Capabilities +- `delegation-task-card-ux`: Covers platform-native delegation task presentation, action buttons, safe fallbacks, and lifecycle-state rendering for shared-room delegation tasks. + +### Modified Capabilities +- None. + +## Impact + +- Affected code: `extensions/relay/commands/delegation.ts`, shared delegation presentation helpers, Slack/Discord/Telegram adapter/runtime task-card send paths, and related unit/live tests. +- APIs: No public command syntax changes; existing `relay task ...` text commands remain supported as fallback. +- Dependencies: No new runtime dependencies expected; Slack rendering should use existing Block Kit support, Discord components, and Telegram inline keyboard support. +- Systems: Slack shared-room delegation UX improves first; Discord and Telegram should either use the same presentation model where supported or retain explicit fallback behavior with tests. diff --git a/openspec/changes/improve-delegation-task-card-ux/specs/delegation-task-card-ux/spec.md b/openspec/changes/improve-delegation-task-card-ux/specs/delegation-task-card-ux/spec.md new file mode 100644 index 0000000..cb2b592 --- /dev/null +++ b/openspec/changes/improve-delegation-task-card-ux/specs/delegation-task-card-ux/spec.md @@ -0,0 +1,88 @@ +## ADDED Requirements + +### Requirement: Structured delegation task presentation +PiRelay SHALL derive delegation task cards from a structured presentation model that separates task lifecycle semantics from messenger-specific rendering. + +#### Scenario: Presentation is derived from a task record +- **WHEN** PiRelay renders a delegation task in any messenger +- **THEN** it derives status, source, target, goal, expiry, claimant, latest result or reason, available actions, and fallback commands from the current delegation task record +- **AND** messenger-specific renderers consume that structured presentation rather than parsing previously rendered text + +#### Scenario: Presentation remains safe and bounded +- **WHEN** the presentation contains user-provided goal, context, source labels, target labels, or latest result text +- **THEN** PiRelay bounds and redacts those fields before rendering +- **AND** it excludes bot tokens, pairing codes, hidden prompts, full transcripts, raw tool inputs, file bytes, upload URLs, and internal session storage keys + +### Requirement: Platform-native delegation task actions +PiRelay SHALL expose delegation task actions as platform-native buttons or equivalent callbacks when the active messenger adapter supports inline actions. + +#### Scenario: Claimable task on a button-capable messenger +- **WHEN** a claimable delegation task is rendered through a messenger adapter that supports inline callbacks +- **THEN** the task card exposes claim, decline, cancel, and status as platform-native actions +- **AND** each action uses the canonical delegation action id for the task and action kind + +#### Scenario: Awaiting approval task on a button-capable messenger +- **WHEN** an awaiting-approval delegation task is rendered through a messenger adapter that supports inline callbacks +- **THEN** the task card exposes approve, cancel, and status as platform-native actions +- **AND** it does not expose claim as the primary action until the task is approved or otherwise claimable + +#### Scenario: Running task on a button-capable messenger +- **WHEN** a claimed or running delegation task is rendered through a messenger adapter that supports inline callbacks +- **THEN** the task card exposes cancel and status as platform-native actions +- **AND** it does not expose claim, approve, or decline actions for that task state + +#### Scenario: Terminal task on a button-capable messenger +- **WHEN** a completed, failed, blocked, declined, cancelled, or expired delegation task is rendered through a messenger adapter that supports inline callbacks +- **THEN** the task card exposes status as the only platform-native task action unless a future requirement explicitly adds safe terminal actions + +### Requirement: Delegation task text fallback +PiRelay SHALL preserve plain-text delegation task commands as a fallback action surface for every rendered task card. + +#### Scenario: Adapter lacks button support +- **WHEN** a delegation task is rendered through a messenger adapter or context that cannot provide inline callbacks +- **THEN** PiRelay renders the available task actions as copyable text commands using the adapter's reliable command prefix + +#### Scenario: Adapter supports button actions +- **WHEN** a delegation task is rendered through a messenger adapter that supports inline callbacks +- **THEN** PiRelay MAY de-emphasize fallback commands in the visible card +- **AND** the fallback commands remain available in bounded text, accessibility text, diagnostics, tests, or another safe fallback surface + +#### Scenario: Text command fallback is used +- **WHEN** an authorized user sends a fallback text command such as `relay task claim ` in an authorized context +- **THEN** PiRelay applies the same task lookup, authorization, idempotency, and lifecycle transition checks as the equivalent platform-native button action + +### Requirement: Readable delegation task lifecycle cards +PiRelay SHALL render delegation task cards so humans can distinguish handoff progress from terminal results. + +#### Scenario: Task is claimable +- **WHEN** PiRelay renders a proposed, claimable, or awaiting-approval task +- **THEN** the card clearly shows that the task has not yet started work +- **AND** it shows source, target, goal, expiry, and the available review or claim actions + +#### Scenario: Task is running +- **WHEN** PiRelay renders a claimed or running task +- **THEN** the card clearly shows that the task has been accepted or handed off but has not necessarily completed +- **AND** it shows the claimant identity when available + +#### Scenario: Task is completed +- **WHEN** delegated work completes with a final result summary +- **THEN** PiRelay renders a completed task card that highlights the bounded latest result summary +- **AND** the card does not rely on the user interpreting a prior running card as completion + +#### Scenario: Task is blocked or failed +- **WHEN** delegated work cannot start, cannot continue, or fails +- **THEN** PiRelay renders a blocked or failed task card that highlights the bounded reason or failure summary +- **AND** it does not claim that delegated work completed successfully + +### Requirement: Shared-room delegation action silence +PiRelay SHALL keep shared-room multi-bot delegation action handling silent for machine bots that do not own or know the referenced task. + +#### Scenario: Non-target bot sees task action for unknown task +- **WHEN** a machine bot in a shared room observes a delegation action or fallback text command for a task id that is not present in its local delegation task state +- **THEN** it remains silent +- **AND** it does not reply with stale-task guidance, mutate state, inject prompts, send activity indicators, or claim ownership of the task + +#### Scenario: Owning bot handles task action +- **WHEN** the machine bot that owns or knows the referenced task observes a valid authorized delegation action +- **THEN** it handles the action according to the existing delegation lifecycle and authorization rules +- **AND** it renders the resulting task update through the platform-appropriate task card surface diff --git a/openspec/changes/improve-delegation-task-card-ux/tasks.md b/openspec/changes/improve-delegation-task-card-ux/tasks.md new file mode 100644 index 0000000..5a948b3 --- /dev/null +++ b/openspec/changes/improve-delegation-task-card-ux/tasks.md @@ -0,0 +1,24 @@ +## 1. Presentation Model + +- [x] 1.1 Add a shared delegation task presentation helper that derives status labels/icons, fields, latest result/reason, actions, fallback commands, and accessibility text from `DelegationTaskRecord`. +- [x] 1.2 Refactor existing plain-text task-card rendering to consume the presentation helper without changing safe redaction or text-command fallback behavior. +- [x] 1.3 Add unit tests for presentation output across claimable, awaiting-approval, running, completed, blocked, failed, cancelled, declined, and expired task states. + +## 2. Adapter Action Rendering + +- [x] 2.1 Add or reuse a shared mapping from `DelegationTaskAction` to `ChannelButtonLayout`, including primary/danger/default styles for claim/approve/cancel/status actions. +- [x] 2.2 Update Slack delegation task sends to include native buttons when actions are available and callbacks are supported. +- [x] 2.3 Ensure Slack cards keep safe fallback text or accessibility text while avoiding the current dense inline action paragraph as the primary UI. +- [x] 2.4 Review Discord and Telegram delegation task sends for parity with the shared presentation model; update only where needed to avoid regressions. + +## 3. Shared-Room and Callback Safety + +- [x] 3.1 Preserve existing authorization, task lookup, idempotency, and lifecycle checks for both button callbacks and fallback text commands. +- [x] 3.2 Ensure shared-room non-owner machine bots remain silent for unknown task ids observed in action callbacks or fallback text commands. +- [x] 3.3 Add regression tests covering stale/unknown task actions, non-target bot silence, and owning-bot task update rendering. + +## 4. Live and Documentation Coverage + +- [x] 4.1 Update Slack live delegation assertions to require completed cards with latest results in real-agent mode and not just running handoff cards. +- [x] 4.2 Update Slack live integration documentation to explain button-first task controls and text-command fallback. +- [x] 4.3 Run targeted validation: `npm run typecheck`, `npm test -- tests/slack-runtime.test.ts tests/slack-adapter.test.ts tests/relay/delegation-commands.test.ts tests/slack-live-delegation.test.ts`, and `openspec validate improve-delegation-task-card-ux --strict`. diff --git a/tests/config.test.ts b/tests/config.test.ts index e12a47e..37ae0ac 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -162,6 +162,20 @@ describe("telegram tunnel config", () => { expect(config.slack).toMatchObject({ enabled: true, botToken: "slack-file", signingSecret: "slack-env-secret", appToken: "xapp-canonical", appId: "A-canonical", eventMode: "webhook", workspaceId: "T-canonical", botUserId: "U-canonical" }); }); + it("rejects invalid delegation policy in runtime compatibility loader", async () => { + const dir = await mkdtemp(join(tmpdir(), "pirelay-config-")); + tempDirs.push(dir); + const configPath = join(dir, "config.json"); + await writeFile(configPath, JSON.stringify({ + messengers: { + telegram: { default: { botToken: "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", delegation: { enabled: true, autonomy: "free-for-all" } } }, + }, + })); + vi.stubEnv("PI_RELAY_CONFIG", configPath); + + await expect(loadTelegramTunnelConfig()).rejects.toThrow(/delegation\.autonomy/); + }); + it("accepts Discord applicationId as the preferred Application ID field", async () => { const dir = await mkdtemp(join(tmpdir(), "pirelay-config-")); tempDirs.push(dir); diff --git a/tests/discord-adapter.test.ts b/tests/discord-adapter.test.ts index 35bf47e..8619582 100644 --- a/tests/discord-adapter.test.ts +++ b/tests/discord-adapter.test.ts @@ -91,7 +91,7 @@ describe("DiscordChannelAdapter", () => { expect(answerInteraction).toHaveBeenCalledWith("interaction-1", undefined, { text: "\\*\\*Done\\*\\*", alert: undefined }); }); - it("rejects bot/webhook messages and only applies image MIME allow-list to images", () => { + it("rejects bot messages unless delegation is enabled and only applies image MIME allow-list to images", () => { expect(discordMessageToChannelEvent({ id: "bot-message", channel_id: "dm1", @@ -99,6 +99,15 @@ describe("DiscordChannelAdapter", () => { content: "loop", attachments: [], }, config)).toBeUndefined(); + const delegatedBotEvent = discordMessageToChannelEvent({ + id: "bot-delegation-message", + channel_id: "c1", + guild_id: "g1", + author: { id: "bot", username: "bot", bot: true }, + content: "relay delegate target run tests", + attachments: [], + }, { ...config, delegation: { enabled: true } }); + expect(delegatedBotEvent?.sender.metadata).toMatchObject({ isBot: true }); const docEvent = discordMessageToChannelEvent({ id: "doc-message", channel_id: "dm1", diff --git a/tests/discord-runtime.test.ts b/tests/discord-runtime.test.ts index 5cc8314..0c5e00c 100644 --- a/tests/discord-runtime.test.ts +++ b/tests/discord-runtime.test.ts @@ -548,6 +548,144 @@ describe("DiscordRuntime", () => { expect(await store.getActiveChannelSelection("discord", "room1", "u1")).toMatchObject({ machineId: "desktop" }); }); + it("handles shared-room delegation cards, trusted peer bots, and claim prompt handoff", async () => { + const cfg = await config({ + applicationId: "123", + allowGuildChannels: true, + allowGuildIds: ["g1"], + sharedRoom: { enabled: true }, + delegation: { + enabled: true, + autonomy: "auto-claim-targeted", + requireHumanApproval: false, + trustedPeers: [{ peerId: "peer-bot", allowCreate: true, targetMachineIds: ["laptop"] }], + }, + }); + cfg.machineId = "laptop"; + cfg.machineDisplayName = "Laptop"; + const ops = new FakeDiscordOperations(); + const runtime = new DiscordRuntime(cfg, { operations: ops }); + const { route: session, sendUserMessage } = route(); + await runtime.registerRoute(session); + await runtime.start(); + const store = new TunnelStateStore(cfg.stateDir); + await store.upsertChannelBinding({ channel: "discord", conversationId: "room1", userId: "u1", sessionKey: session.sessionKey, sessionId: session.sessionId, sessionLabel: session.sessionLabel, boundAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(), metadata: { conversationKind: "channel" } }); + + await ops.handler?.(discordMessage("relay delegate laptop run docs tests", { channelId: "room1", guildId: "g1" })); + const beforeTasks = await store.listDelegationTasks({ roomConversationId: "room1" }); + expect(ops.messages.some((message) => message.content.includes("Delegation task-"))).toBe(true); + expect(ops.messages.at(-1)?.components?.[0]?.[0]).toMatchObject({ label: "Claim" }); + + const [task] = beforeTasks; + expect(task).toMatchObject({ status: "claimable", target: { kind: "machine", machineId: "laptop" } }); + + await ops.handler?.(discordMessage(`relay task claim ${task!.id}`, { channelId: "room1", guildId: "g1" })); + expect(sendUserMessage).toHaveBeenCalledWith(expect.stringContaining(`delegated task ${task!.id}`)); + expect(await store.getDelegationTask(task!.id)).toMatchObject({ status: "running", claimedBy: { sessionKey: session.sessionKey } }); + await store.upsertChannelBinding({ channel: "discord", conversationId: "dm-leak", userId: "u1", sessionKey: session.sessionKey, sessionId: session.sessionId, sessionLabel: session.sessionLabel, boundAt: new Date().toISOString(), lastSeenAt: new Date().toISOString() }); + + session.notification.lastAssistantText = "Docs tests passed."; + await runtime.notifyTurnCompleted(session, "completed"); + expect(ops.messages.at(-1)?.content).toContain("Status: Completed"); + expect(ops.messages.some((message) => message.channelId === "dm-leak" && message.content.includes("Docs tests passed"))).toBe(false); + + await store.upsertChannelBinding({ channel: "discord", conversationId: "room1", userId: "u1", sessionKey: session.sessionKey, sessionId: session.sessionId, sessionLabel: session.sessionLabel, boundAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(), metadata: { conversationKind: "channel" } }); + + await ops.handler?.(discordMessage("relay delegate laptop should be ignored", { channelId: "room1", guildId: "g1", userId: "unknown-bot", bot: true })); + expect((await store.listDelegationTasks({ roomConversationId: "room1" })).length).toBe(1); + + await ops.handler?.(discordMessage("relay delegate laptop trusted task", { channelId: "room1", guildId: "g1", userId: "peer-bot", bot: true })); + const tasks = await store.listDelegationTasks({ roomConversationId: "room1" }); + expect(tasks.some((candidate) => candidate.sourceMachineId === "peer-bot")).toBe(true); + }); + + it("routes shared-room delegation cards only to locally targeted Discord bot", async () => { + const laptopCfg = await config({ applicationId: "123", allowGuildChannels: true, allowGuildIds: ["g1"], sharedRoom: { enabled: true }, delegation: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false } }); + laptopCfg.machineId = "laptop"; + laptopCfg.machineDisplayName = "Laptop"; + laptopCfg.machineAliases = ["lap"]; + const desktopCfg = await config({ applicationId: "123", allowGuildChannels: true, allowGuildIds: ["g1"], sharedRoom: { enabled: true }, delegation: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false } }); + desktopCfg.machineId = "desktop"; + desktopCfg.machineDisplayName = "Desktop"; + desktopCfg.machineAliases = ["desk"]; + + const laptopOps = new FakeDiscordOperations(); + const desktopOps = new FakeDiscordOperations(); + const laptopRuntime = new DiscordRuntime(laptopCfg, { operations: laptopOps }); + const desktopRuntime = new DiscordRuntime(desktopCfg, { operations: desktopOps }); + const { route: laptopRoute } = route(); + const { route: desktopRoute } = route(); + desktopRoute.sessionKey = "desktop-session:memory"; + desktopRoute.sessionId = "desktop-session"; + desktopRoute.sessionLabel = "API"; + + await laptopRuntime.registerRoute(laptopRoute); + await desktopRuntime.registerRoute(desktopRoute); + await laptopRuntime.start(); + await desktopRuntime.start(); + + const now = new Date().toISOString(); + const nowStore = new TunnelStateStore(laptopCfg.stateDir); + await nowStore.upsertChannelBinding({ channel: "discord", conversationId: "room1", userId: "u1", sessionKey: laptopRoute.sessionKey, sessionId: laptopRoute.sessionId, sessionLabel: laptopRoute.sessionLabel, metadata: { alias: "docs" }, boundAt: now, lastSeenAt: now }); + const otherStore = new TunnelStateStore(desktopCfg.stateDir); + await otherStore.upsertChannelBinding({ channel: "discord", conversationId: "room1", userId: "u1", sessionKey: desktopRoute.sessionKey, sessionId: desktopRoute.sessionId, sessionLabel: desktopRoute.sessionLabel, metadata: { alias: "api" }, boundAt: now, lastSeenAt: now }); + + await laptopOps.handler?.(discordMessage("relay delegate laptop run docs tests", { channelId: "room1", guildId: "g1" })); + await desktopOps.handler?.(discordMessage("relay delegate laptop run docs tests", { channelId: "room1", guildId: "g1" })); + + expect(laptopOps.messages.some((message) => message.content.includes("Delegation task-") )).toBe(true); + expect(desktopOps.messages).toHaveLength(0); + }); + + it("keeps bot-authored Discord delegation text inert outside shared rooms", async () => { + const cfg = await config({ allowUserIds: [], delegation: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false } }); + cfg.machineId = "local"; + const ops = new FakeDiscordOperations(); + const runtime = new DiscordRuntime(cfg, { operations: ops }); + const { route: testRoute, sendUserMessage } = route(); + await runtime.registerRoute(testRoute); + await runtime.start(); + + const store = new TunnelStateStore(cfg.stateDir); + const now = new Date().toISOString(); + await store.upsertChannelBinding({ channel: "discord", conversationId: "dm1", userId: "peer-bot", sessionKey: testRoute.sessionKey, sessionId: testRoute.sessionId, sessionLabel: testRoute.sessionLabel, boundAt: now, lastSeenAt: now }); + + await ops.handler?.(discordMessage("relay delegate local run tests", { userId: "peer-bot", bot: true })); + + expect(sendUserMessage).not.toHaveBeenCalled(); + expect(await store.listDelegationTasks({ roomConversationId: "dm1" })).toHaveLength(0); + }); + + it("does not let unauthorized Discord users mutate shared-room selection during pre-routing", async () => { + const cfg = await config({ applicationId: "123", allowGuildChannels: true, allowGuildIds: ["g1"], allowUserIds: ["u1"], sharedRoom: { enabled: true } }); + const ops = new FakeDiscordOperations(); + const runtime = new DiscordRuntime(cfg, { operations: ops }); + await runtime.start(); + + await ops.handler?.(discordMessage("relay use remote Docs", { userId: "u2", channelId: "room1", guildId: "g1" })); + + const store = new TunnelStateStore(cfg.stateDir); + await expect(store.getActiveChannelSelection("discord", "room1", "u2")).resolves.toBeUndefined(); + }); + + it("requires Discord capability delegation creation to be source-scoped", async () => { + const cfg = await config({ applicationId: "123", allowGuildChannels: true, allowGuildIds: ["g1"], sharedRoom: { enabled: true }, delegation: { enabled: true, autonomy: "auto-claim-safe-capability", requireHumanApproval: false, localCapabilities: ["linux-tests"] } }); + const ops = new FakeDiscordOperations(); + const runtime = new DiscordRuntime(cfg, { operations: ops }); + const { route: testRoute } = route(); + await runtime.registerRoute(testRoute); + await runtime.start(); + const store = new TunnelStateStore(cfg.stateDir); + const now = new Date().toISOString(); + await store.upsertChannelBinding({ channel: "discord", conversationId: "room1", userId: "u1", sessionKey: testRoute.sessionKey, sessionId: testRoute.sessionId, sessionLabel: testRoute.sessionLabel, boundAt: now, lastSeenAt: now }); + + await ops.handler?.(discordMessage("relay delegate #linux-tests run tests", { channelId: "room1", guildId: "g1" })); + expect(await store.listDelegationTasks({ roomConversationId: "room1" })).toHaveLength(0); + + await ops.handler?.(discordMessage("<@123> relay delegate #linux-tests run tests", { channelId: "room1", guildId: "g1", mentions: [{ id: "123", bot: true }] })); + expect(await store.listDelegationTasks({ roomConversationId: "room1" })).toEqual([expect.objectContaining({ target: { kind: "capability", capability: "linux-tests" } })]); + }); + it("does not treat arbitrary Discord user mentions as remote bot targeting", async () => { const cfg = await config({ applicationId: "123", allowGuildChannels: true, allowGuildIds: ["g1"], sharedRoom: { enabled: true } }); cfg.machineId = "laptop"; diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 4e53eba..5568664 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -34,9 +34,11 @@ async function flushAsyncActions(): Promise { async function createRuntimeConfig(prefix = "pi-telegram-integration-"): Promise { const stateDir = await mkdtemp(join(tmpdir(), prefix)); tempDirs.push(stateDir); + const configPath = join(stateDir, "config.json"); + vi.stubEnv("PI_RELAY_CONFIG", configPath); return { botToken: "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", - configPath: join(stateDir, "config.json"), + configPath, stateDir, pairingExpiryMs: 300_000, busyDeliveryMode: "followUp", @@ -418,8 +420,8 @@ describe("PiRelay integration behavior", () => { await pi.runCommand("relay", "status", context); expect(statuses).toContainEqual({ key: "relay-sync", value: "telegram sync error: broker unavailable" }); - expect(statuses).toContainEqual({ key: "relay", value: "telegram error: broker unavailable" }); - expect(statuses).not.toContainEqual({ key: "relay", value: "telegram: ready unpaired" }); + expect(statuses).toContainEqual({ key: "relay", value: "tg ✖" }); + expect(statuses).not.toContainEqual({ key: "relay", value: "tg ◇" }); }); it("sends Telegram lifecycle notifications for offline, restored, and disconnect events", async () => { @@ -438,6 +440,7 @@ describe("PiRelay integration behavior", () => { setup: undefined, start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined), + restartBrokerProcess: vi.fn(async () => undefined), ensureSetup: vi.fn(async () => ({ botId: 123456, botUsername: "pi_test_bot", botDisplayName: "Pi Test Bot", validatedAt: new Date().toISOString() })), registerRoute: vi.fn(async () => undefined), unregisterRoute: vi.fn(async () => undefined), @@ -499,7 +502,7 @@ describe("PiRelay integration behavior", () => { const { default: relayExtension } = await import("../extensions/relay/index.js"); const pi = createMockPi(); - const { context, statuses } = createMockContext(sessionId); + const { context, statuses, notifications } = createMockContext(sessionId); relayExtension(pi.api as any); await pi.emit("session_start", {}, context); @@ -539,7 +542,7 @@ describe("PiRelay integration behavior", () => { const { default: relayExtension } = await import("../extensions/relay/index.js"); const pi = createMockPi(); - const { context, statuses } = createMockContext(sessionId); + const { context, statuses, notifications } = createMockContext(sessionId); relayExtension(pi.api as any); await pi.emit("session_start", {}, context); @@ -548,7 +551,11 @@ describe("PiRelay integration behavior", () => { await shutdown; expect(fakeRuntime.unregisterRoute).toHaveBeenCalledWith(binding.sessionKey); - expect(statuses).toContainEqual({ key: "relay-lifecycle", value: "relay lifecycle warning: lifecycle notification timed out" }); + expect(statuses).toContainEqual({ key: "relay-lifecycle", value: "relay ⚠" }); + + await pi.runCommand("relay", "doctor", context); + expect(notifications.at(-1)?.message).toContain("Runtime status details:"); + expect(notifications.at(-1)?.message).toContain("telegram lifecycle timed out"); }); it("unregisters Discord routes on session shutdown", async () => { @@ -631,7 +638,7 @@ describe("PiRelay integration behavior", () => { await pi.emit("session_shutdown", {}, context); expect(fakeRuntime.unregisterRoute).toHaveBeenCalledWith(binding.sessionKey); - expect(statuses).toContainEqual({ key: "relay-lifecycle", value: "relay lifecycle warning: network down" }); + expect(statuses).toContainEqual({ key: "relay-lifecycle", value: "relay ⚠" }); }); it("opens interactive setup wizard when UI is available", async () => { @@ -892,6 +899,7 @@ describe("PiRelay integration behavior", () => { setup: undefined, start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined), + restartBrokerProcess: vi.fn(async () => undefined), ensureSetup: vi.fn(async () => ({ botId: 123456, botUsername: "pi_test_bot", botDisplayName: "Pi Test Bot", validatedAt: new Date().toISOString() })), registerRoute: vi.fn(async () => undefined), unregisterRoute: vi.fn(async () => undefined), @@ -934,8 +942,64 @@ describe("PiRelay integration behavior", () => { expect(fakeSlackRuntime.stop).toHaveBeenCalledTimes(1); expect(fakeRuntime.stop).toHaveBeenCalledTimes(1); - expect(statuses).toContainEqual({ key: "relay", value: "telegram: starting" }); - expect(statuses).toContainEqual({ key: "slack-relay", value: "slack: starting" }); + expect(statuses).toContainEqual({ key: "relay", value: "tg ◌" }); + expect(statuses).toContainEqual({ key: "slack-relay", value: "sl ◌" }); + }); + + it("restarts active relay runtimes on demand", async () => { + const config = await createRuntimeConfig("pi-relay-restart-command-"); + vi.stubEnv("TELEGRAM_BOT_TOKEN", config.botToken); + vi.stubEnv("PI_TELEGRAM_TUNNEL_STATE_DIR", config.stateDir); + vi.stubEnv("PI_RELAY_SLACK_ENABLED", "true"); + vi.stubEnv("PI_RELAY_SLACK_BOT_TOKEN", "slack-token"); + vi.stubEnv("PI_RELAY_SLACK_SIGNING_SECRET", "slack-secret"); + vi.stubEnv("PI_RELAY_SLACK_APP_TOKEN", "xapp-token"); + const fakeRuntime: TunnelRuntime = { + setup: undefined, + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + restartBrokerProcess: vi.fn(async () => undefined), + ensureSetup: vi.fn(async () => ({ botId: 123456, botUsername: "pi_test_bot", botDisplayName: "Pi Test Bot", validatedAt: new Date().toISOString() })), + registerRoute: vi.fn(async () => undefined), + unregisterRoute: vi.fn(async () => undefined), + getStatus: vi.fn(() => undefined), + sendToBoundChat: vi.fn(async () => undefined), + }; + let slackStarted = false; + const fakeSlackRuntime = { + start: vi.fn(async () => { slackStarted = true; }), + stop: vi.fn(async () => { slackStarted = false; }), + registerRoute: vi.fn(async () => undefined), + unregisterRoute: vi.fn(async () => undefined), + getStatus: vi.fn(() => ({ enabled: true, started: slackStarted, appId: "A1", teamId: "T1" })), + }; + vi.doMock("../extensions/relay/adapters/telegram/runtime.js", () => ({ + getOrCreateTunnelRuntime: () => fakeRuntime, + sendSessionNotification: vi.fn(async () => undefined), + })); + vi.doMock("../extensions/relay/adapters/slack/runtime.js", () => ({ + getOrCreateSlackRuntime: () => fakeSlackRuntime, + })); + + const { default: relayExtension } = await import("../extensions/relay/index.js"); + const pi = createMockPi(); + const { context, notifications, statuses } = createMockContext("relay-restart-command"); + relayExtension(pi.api as any); + + await pi.runCommand("relay", "connect slack", context); + expect(fakeRuntime.registerRoute).toHaveBeenCalledTimes(1); + expect(fakeSlackRuntime.start).toHaveBeenCalledTimes(1); + + await pi.runCommand("relay", "restart", context); + + expect(fakeRuntime.restartBrokerProcess).toHaveBeenCalledTimes(1); + expect(fakeRuntime.stop).not.toHaveBeenCalled(); + expect(fakeSlackRuntime.stop).toHaveBeenCalledTimes(1); + expect(fakeRuntime.registerRoute).toHaveBeenCalledTimes(2); + expect(fakeSlackRuntime.start).toHaveBeenCalledTimes(2); + expect(notifications.at(-1)?.message).toBe("PiRelay broker process and runtimes restarted for this session."); + expect(statuses).toContainEqual({ key: "relay", value: "tg ◌" }); + expect(statuses).toContainEqual({ key: "slack-relay", value: "sl ◌" }); }); it("does not show Slack ready when required credentials are incomplete", async () => { @@ -979,7 +1043,7 @@ describe("PiRelay integration behavior", () => { await pi.runCommand("relay", "status", context); - expect(statuses).toContainEqual({ key: "slack-relay", value: "slack: off" }); + expect(statuses).toContainEqual({ key: "slack-relay", value: "sl ○" }); }); it("scopes Slack status lines by configured instance", async () => { @@ -1040,8 +1104,8 @@ describe("PiRelay integration behavior", () => { await pi.runCommand("relay", "status", context); - expect(statuses).toContainEqual({ key: "slack-relay", value: "slack: ready unpaired" }); - expect(statuses).toContainEqual({ key: "slack-relay:beta", value: "slack: paired channel" }); + expect(statuses).toContainEqual({ key: "slack-relay", value: "sl ◇" }); + expect(statuses).toContainEqual({ key: "slack-relay:beta", value: "sl ● #" }); }); it("does not write setup config when confirmation is cancelled", async () => { @@ -1161,6 +1225,46 @@ describe("PiRelay integration behavior", () => { expect(notifications.some((entry) => entry.message.includes("Relay setup doctor"))).toBe(true); }); + it("keeps paired Telegram status visible when state is temporarily unavailable", async () => { + const config = await createRuntimeConfig("pi-status-volatile-state-unavailable-"); + vi.stubEnv("TELEGRAM_BOT_TOKEN", config.botToken); + vi.stubEnv("PI_TELEGRAM_TUNNEL_STATE_DIR", config.stateDir); + const sessionId = "volatile-state-status"; + const binding = createBinding(sessionId, 7001, 9001); + binding.sessionKey = sessionKeyOf(sessionId, `/tmp/${sessionId}.jsonl`); + binding.sessionFile = `/tmp/${sessionId}.jsonl`; + binding.sessionLabel = "volatile-state-status.jsonl"; + const fakeRuntime: TunnelRuntime = { + setup: undefined, + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + ensureSetup: vi.fn(async () => ({ botId: 123456, botUsername: "pi_test_bot", botDisplayName: "Pi Test Bot", validatedAt: new Date().toISOString() })), + registerRoute: vi.fn(async () => undefined), + unregisterRoute: vi.fn(async () => undefined), + getStatus: vi.fn(() => undefined), + sendToBoundChat: vi.fn(async () => undefined), + }; + vi.doMock("../extensions/relay/adapters/telegram/runtime.js", () => ({ + getOrCreateTunnelRuntime: () => fakeRuntime, + sendSessionNotification: vi.fn(async () => undefined), + })); + + const { default: relayExtension } = await import("../extensions/relay/index.js"); + const pi = createMockPi(); + const { context, branch, statuses } = createMockContext(sessionId); + branch.push({ type: "custom", customType: "relay-binding", data: { version: 1, binding, revoked: false } }); + relayExtension(pi.api as any); + + await pi.emit("session_start", {}, context); + expect(statuses).toContainEqual({ key: "relay", value: "tg ● ✉" }); + + await writeFile(join(config.stateDir, "state.json"), "{not-json", "utf8"); + await pi.runCommand("relay", "status", context); + + expect(statuses).toContainEqual({ key: "relay", value: "tg ● ✉" }); + expect(statuses).not.toContainEqual({ key: "relay", value: "tg ◇" }); + }); + it("keeps local prompts and skill commands usable after connect, pairing, and route sync", async () => { const config = await createRuntimeConfig("pi-telegram-extension-"); vi.stubEnv("TELEGRAM_BOT_TOKEN", config.botToken); @@ -1209,7 +1313,7 @@ describe("PiRelay integration behavior", () => { expect(renderer({ content: "Slack paired with U5FUPDYM9." }, {}, { fg: (_name: string, text: string) => text }).render(120)[0]).toBe("Relay › Slack paired with U5FUPDYM9."); await pi.runCommand("relay", "connect telegram", context); - expect(statuses).toContainEqual({ key: "relay", value: "telegram: ready unpaired" }); + expect(statuses).toContainEqual({ key: "relay", value: "tg ◇" }); const route = [...registeredRoutes.values()][0]; expect(route).toBeDefined(); @@ -1219,9 +1323,13 @@ describe("PiRelay integration behavior", () => { binding.sessionLabel = route!.sessionLabel; route!.binding = binding; route!.actions.persistBinding(binding, false); + await waitFor(() => statuses.some((entry) => entry.key === "relay" && entry.value === "tg ● ✉")); + route!.binding = undefined; + route!.actions.refreshLocalStatus?.(); + await waitFor(() => statuses.some((entry) => entry.key === "relay" && entry.value === "tg ● ✉")); route!.actions.appendAudit("Telegram relay paired with @owner."); route!.actions.notifyLocal?.("Telegram paired with @owner for docs.", "info"); - await waitFor(() => statuses.some((entry) => entry.key === "relay" && entry.value === "telegram: paired dm")); + await waitFor(() => statuses.some((entry) => entry.key === "relay" && entry.value === "tg ● ✉")); branch.push(...pi.appendedEntries); blockFutureRouteSync = true; @@ -1338,13 +1446,13 @@ describe("PiRelay integration behavior", () => { expect(fakeDiscordRuntime.registerRoute).toHaveBeenCalled(); expect(fakeDiscordRuntime.start).toHaveBeenCalledTimes(1); - expect(statuses).toContainEqual({ key: "discord-relay", value: "discord: ready unpaired" }); + expect(statuses).toContainEqual({ key: "discord-relay", value: "dc ◇" }); const discordRouteCall = fakeDiscordRuntime.registerRoute.mock.calls.at(-1) as unknown[] | undefined; const discordRoute = discordRouteCall?.[0] as SessionRoute; const discordStore = new TunnelStateStore(config.stateDir); await discordStore.upsertChannelBinding({ channel: "discord", instanceId: "default", conversationId: "D1", userId: "U1", sessionKey: discordRoute.sessionKey, sessionId: discordRoute.sessionId, sessionLabel: discordRoute.sessionLabel, boundAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(), metadata: { conversationKind: "private" } }); discordRoute.actions.notifyLocal?.("Discord paired with U1 for docs.", "info"); - await waitFor(() => statuses.some((entry) => entry.key === "discord-relay" && entry.value === "discord: paired dm")); + await waitFor(() => statuses.some((entry) => entry.key === "discord-relay" && entry.value === "dc ● ✉")); expect(notifications.some((entry) => entry.message.includes("relay pair"))).toBe(true); expect(notifications.some((entry) => entry.message.includes("QR redirect unavailable"))).toBe(true); const store = new TunnelStateStore(config.stateDir); @@ -1508,7 +1616,7 @@ describe("PiRelay integration behavior", () => { expect(clipboardTexts.at(-1)).toMatch(/^relay pair \d{3}-\d{3}\n$/); expect(closeCount).toBe(0); expect(fakeSlackRuntime.start).toHaveBeenCalledTimes(1); - expect(statuses).toContainEqual({ key: "slack-relay", value: "slack: ready unpaired" }); + expect(statuses).toContainEqual({ key: "slack-relay", value: "sl ◇" }); expect(notifications.some((entry) => entry.message.includes("pairing command copied to clipboard"))).toBe(true); const registerCallsAfterConnect = fakeSlackRuntime.registerRoute.mock.calls.length; const routeForPairingCall = fakeSlackRuntime.registerRoute.mock.calls.at(-1) as unknown[] | undefined; @@ -1520,7 +1628,7 @@ describe("PiRelay integration behavior", () => { expect(closeCount).toBe(1); expect(notifications.some((entry) => entry.message.includes("Slack pairing PIN ready"))).toBe(true); expect(notifications).toContainEqual({ message: "Slack paired with U5FUPDYM9 for docs.", level: "info" }); - await waitFor(() => statuses.some((entry) => entry.key === "slack-relay" && entry.value === "slack: paired dm")); + await waitFor(() => statuses.some((entry) => entry.key === "slack-relay" && entry.value === "sl ● ✉")); await pi.emit("agent_start", {}, context); expect(fakeSlackRuntime.registerRoute.mock.calls.length).toBeGreaterThan(registerCallsAfterConnect); const latestRegisterCall = fakeSlackRuntime.registerRoute.mock.calls.at(-1) as unknown[] | undefined; @@ -2831,4 +2939,58 @@ describe("PiRelay integration behavior", () => { expect(deliveries).toEqual([{ text: "after reconnect prompt", deliverAs: undefined }]); }); + + it("keeps Telegram human delegation behind private pairing while allowing trusted peer bots", async () => { + const config = await createRuntimeConfig("pi-telegram-delegation-auth-"); + config.delegation = { + enabled: true, + autonomy: "propose-only", + trustedPeers: [{ peerId: "999", allowCreate: true, targetMachineIds: ["local"], conversationIds: ["-1001"] }], + }; + const store = new TunnelStateStore(config.stateDir); + await store.setSetup({ botId: 123456, botUsername: "pirelay_bot", botDisplayName: "PiRelay", validatedAt: new Date().toISOString() }); + const runtime = new InProcessTunnelRuntime(config, store); + const sent: string[] = []; + const callbackAnswers: string[] = []; + (runtime as any).api = { + sendPlainText: async (_chatId: number, text: string) => sent.push(text), + sendPlainTextWithKeyboard: async (_chatId: number, text: string) => sent.push(text), + sendChatAction: async () => undefined, + answerCallbackQuery: async (_id: string, text?: string) => callbackAnswers.push(text ?? ""), + }; + + await (runtime as any).processInbound({ + updateId: 10, + messageId: 10, + text: "/delegate@pirelay_bot local run tests", + chat: { id: -1001, type: "supergroup", title: "ops" }, + user: { id: 42, username: "human", isBot: false }, + }); + + expect(sent.at(-1)).toContain("Pair with this bot in a private Telegram chat first"); + expect(await store.listDelegationTasks({ roomConversationId: "-1001" })).toHaveLength(0); + + await (runtime as any).processInbound({ + updateId: 11, + messageId: 11, + text: "/delegate@pirelay_bot local run trusted peer task", + chat: { id: -1001, type: "supergroup", title: "ops" }, + user: { id: 999, username: "peer", isBot: true }, + }); + + const [task] = await store.listDelegationTasks({ roomConversationId: "-1001" }); + expect(task).toMatchObject({ status: "awaiting-approval" }); + + await (runtime as any).processInbound({ + kind: "callback", + updateId: 12, + callbackQueryId: "delegation-cb-1", + data: `pirelay:delegation:approve:${task!.id}`, + chat: { id: -1001, type: "supergroup", title: "ops" }, + user: { id: 42, username: "human", isBot: false }, + }); + + expect(callbackAnswers.at(-1)).toBe("Pair privately first."); + expect(await store.getDelegationTask(task!.id)).toMatchObject({ status: "awaiting-approval" }); + }); }); diff --git a/tests/relay-setup.test.ts b/tests/relay-setup.test.ts index c82b90a..bec4cf6 100644 --- a/tests/relay-setup.test.ts +++ b/tests/relay-setup.test.ts @@ -41,6 +41,7 @@ describe("relay setup wizard helpers", () => { expect(completeRelayLocalCommand("connect di")).toEqual(["connect discord"]); expect(completeRelayLocalCommand("setup sl")).toEqual(["setup slack"]); expect(completeRelayLocalCommand("send-file ")).toEqual(["send-file all", "send-file telegram", "send-file discord", "send-file slack"]); + expect(completeRelayLocalCommand("res")).toEqual(["restart"]); expect(completeRelayLocalCommand("connect di", { compatibilityCommand: true })).toBeNull(); }); @@ -54,6 +55,7 @@ describe("relay setup wizard helpers", () => { expect(parseRelayLocalCommand("connect slack:")).toMatchObject({ subcommand: "connect", unsupportedChannel: "slack:" }); expect(parseRelayLocalCommand("setup matrix")).toMatchObject({ subcommand: "setup", unsupportedChannel: "matrix" }); expect(parseRelayLocalCommand("doctor")).toEqual({ subcommand: "doctor", args: "" }); + expect(parseRelayLocalCommand("restart")).toEqual({ subcommand: "restart", args: "" }); }); it("renders secret-safe doctor output and permission diagnostics", () => { diff --git a/tests/relay/agent-delegation-approval.test.ts b/tests/relay/agent-delegation-approval.test.ts new file mode 100644 index 0000000..df05694 --- /dev/null +++ b/tests/relay/agent-delegation-approval.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + createDelegationApprovalGrant, + delegationApprovalGrantMatches, + delegationApprovalOptions, + formatDelegationApprovalSummary, +} from "../../extensions/relay/core/agent-delegation-approval.js"; + +const operation = { + taskId: "task-1", + sessionKey: "session-1:memory", + requesterKey: "discord:default:C1:U1", + bindingKey: "discord:default:session-1", + matcherFingerprint: "tool:read-file", + toolName: "read", + category: "filesystem", + expiresAt: "2026-05-15T00:10:00.000Z", +}; + +describe("delegation approval helpers", () => { + it("creates task-scoped grants that do not escape the task", () => { + const grant = createDelegationApprovalGrant({ ...operation, scope: "task", now: "2026-05-15T00:00:00.000Z" }); + expect(grant).toMatchObject({ scope: "task", taskId: "task-1", sessionKey: "session-1:memory" }); + expect(delegationApprovalGrantMatches(grant, operation, "2026-05-15T00:01:00.000Z")).toBe(true); + expect(delegationApprovalGrantMatches(grant, { ...operation, taskId: "task-2" }, "2026-05-15T00:01:00.000Z")).toBe(false); + expect(delegationApprovalGrantMatches(grant, { ...operation, sessionKey: "other" }, "2026-05-15T00:01:00.000Z")).toBe(false); + expect(delegationApprovalGrantMatches(grant, { ...operation, bindingKey: "discord:default:other" }, "2026-05-15T00:01:00.000Z")).toBe(false); + expect(delegationApprovalGrantMatches({ ...grant, revokedAt: "2026-05-15T00:02:00.000Z" }, operation, "2026-05-15T00:03:00.000Z")).toBe(false); + expect(delegationApprovalGrantMatches(grant, operation, "2026-05-15T00:11:00.000Z")).toBe(false); + }); + + it("keeps session grants narrower than persistent grants and requires task ids for task scope", () => { + expect(() => createDelegationApprovalGrant({ ...operation, taskId: undefined, scope: "task" })).toThrow(/task id/); + const sessionGrant = createDelegationApprovalGrant({ ...operation, scope: "session" }); + expect(sessionGrant.taskId).toBeUndefined(); + expect(delegationApprovalGrantMatches(sessionGrant, { ...operation, taskId: "task-2" }, "2026-05-15T00:01:00.000Z")).toBe(true); + expect(delegationApprovalGrantMatches(sessionGrant, { ...operation, matcherFingerprint: "tool:write" }, "2026-05-15T00:01:00.000Z")).toBe(false); + }); + + it("renders approval options and safe summaries", () => { + expect(delegationApprovalOptions({ taskId: "task-1", allowSessionGrant: true }).map((option) => option.id)).toEqual(["approve-once", "approve-for-task", "approve-for-session", "deny"]); + expect(delegationApprovalOptions({ allowPersistentGrant: true }).map((option) => option.id)).toEqual(["approve-once", "approve-persistent", "deny"]); + expect(delegationApprovalOptions().map((option) => option.id)).toEqual(["approve-once", "deny"]); + const summary = formatDelegationApprovalSummary({ ...operation, toolName: "TOKEN=secret-value" }); + expect(summary).toContain("Delegated task: task-1"); + expect(summary).toContain("[redacted]"); + expect(summary).not.toContain("secret-value"); + }); +}); diff --git a/tests/relay/agent-delegation-runtime.test.ts b/tests/relay/agent-delegation-runtime.test.ts new file mode 100644 index 0000000..e0c622c --- /dev/null +++ b/tests/relay/agent-delegation-runtime.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import { createDelegationTask } from "../../extensions/relay/core/agent-delegation.js"; +import { + buildDelegatedTaskPrompt, + delegationCommandFromAction, + delegationRoomFromMessage, + evaluateDelegationIngress, + isPeerBotIdentity, + isSelfAuthoredDelegationEvent, + resolveDelegationRuntimePolicy, +} from "../../extensions/relay/core/agent-delegation-runtime.js"; +import type { ChannelInboundAction, ChannelInboundMessage } from "../../extensions/relay/core/channel-adapter.js"; +import type { SessionRoute } from "../../extensions/relay/core/types.js"; + +const message: ChannelInboundMessage = { + kind: "message", + channel: "discord", + updateId: "evt-1", + messageId: "m1", + text: "/delegate target run tests", + attachments: [], + conversation: { channel: "discord", id: "C1", kind: "group" }, + sender: { channel: "discord", userId: "U1", displayName: "Owner" }, +}; + +const botMessage: ChannelInboundMessage = { + ...message, + sender: { channel: "discord", userId: "bot-a", displayName: "Bot A", metadata: { isBot: true } }, +}; + +const room = delegationRoomFromMessage(message, "default"); + +function route(overrides: Partial = {}): SessionRoute { + return { + sessionKey: "s1", + sessionId: "s1", + sessionLabel: "Tests", + notification: {}, + actions: {} as never, + ...overrides, + }; +} + +describe("agent delegation runtime helpers", () => { + it("resolves disabled-by-default policy and fails closed for invalid autonomy", () => { + expect(resolveDelegationRuntimePolicy(undefined)).toMatchObject({ enabled: false, autonomy: "off", trustedPeers: [] }); + expect(resolveDelegationRuntimePolicy({ enabled: true, localCapabilities: ["tests"] }, ["docs"])).toMatchObject({ enabled: true, autonomy: "propose-only", localCapabilities: ["docs", "tests"] }); + expect(resolveDelegationRuntimePolicy({ enabled: true, autonomy: "free-for-all" as never })).toMatchObject({ enabled: false, autonomy: "off" }); + }); + + it("ignores self-authored and ordinary non-delegation events", async () => { + expect(isSelfAuthoredDelegationEvent({ channel: "slack", userId: "B1" }, "B1")).toBe(true); + expect(await evaluateDelegationIngress({ + command: undefined, + message, + policy: { enabled: true }, + room, + localMachineId: "target", + isAuthorizedHuman: true, + })).toEqual({ kind: "ignore", reason: "not-delegation" }); + expect(await evaluateDelegationIngress({ + command: { kind: "history" }, + message: { ...message, sender: { channel: "discord", userId: "local-bot" } }, + policy: { enabled: true }, + room, + localMachineId: "target", + localBotUserId: "local-bot", + isAuthorizedHuman: true, + })).toEqual({ kind: "ignore", reason: "self-authored" }); + }); + + it("creates task cards for authorized humans", async () => { + const decision = await evaluateDelegationIngress({ + command: { kind: "create", target: { kind: "machine", machineId: "target" }, goal: "run tests", rawGoal: "run tests", awaitApproval: false }, + message, + policy: { enabled: true, requireHumanApproval: false, taskExpiryMs: 60000 }, + room, + localMachineId: "source", + localMachineLabel: "Source", + isAuthorizedHuman: true, + now: "2026-05-15T00:00:00.000Z", + }); + expect(decision).toMatchObject({ kind: "render-task", task: { status: "claimable", goal: "run tests" } }); + }); + + it("keeps peer bot trust action-scoped and rejects untrusted bot-authored tasks", async () => { + expect(isPeerBotIdentity(botMessage.sender)).toBe(true); + expect(await evaluateDelegationIngress({ + command: { kind: "create", target: { kind: "machine", machineId: "target" }, goal: "run tests", rawGoal: "run tests", awaitApproval: false }, + message: botMessage, + policy: { enabled: true, trustedPeers: [] }, + room, + localMachineId: "target", + isAuthorizedHuman: false, + })).toMatchObject({ kind: "ignore", reason: "untrusted-peer" }); + + const trusted = await evaluateDelegationIngress({ + command: { kind: "create", target: { kind: "machine", machineId: "target" }, goal: "run tests", rawGoal: "run tests", awaitApproval: false }, + message: botMessage, + policy: { enabled: true, trustedPeers: [{ peerId: "bot-a", allowCreate: true, targetMachineIds: ["target"] }] }, + room, + localMachineId: "target", + isAuthorizedHuman: false, + }); + expect(trusted).toMatchObject({ kind: "render-task", task: { status: "awaiting-approval", sourceMachineId: "bot-a" } }); + + const task = createDelegationTask({ id: "task-peer-control", sourceMachineId: "bot-a", target: { kind: "machine", machineId: "target" }, goal: "run tests", room, expiryMs: 60000, status: "claimable" }); + expect(await evaluateDelegationIngress({ + command: { kind: "cancel", taskId: task.id }, + message: botMessage, + policy: { enabled: true, trustedPeers: [{ peerId: "bot-a", allowCreate: true, targetMachineIds: ["target"] }] }, + room, + localMachineId: "target", + isAuthorizedHuman: false, + lookup: { get: async () => task, list: async () => [task] }, + })).toMatchObject({ kind: "ignore", reason: "untrusted-peer" }); + }); + + it("approves awaiting-approval tasks without leaking across rooms", async () => { + const task = createDelegationTask({ + id: "task-approve", + sourceMachineId: "bot-a", + target: { kind: "machine", machineId: "target" }, + goal: "run tests", + room, + expiryMs: 60000, + status: "awaiting-approval", + createdAt: "2026-05-15T00:00:00.000Z", + }); + const lookup = { get: async () => task, list: async () => [task] }; + expect(await evaluateDelegationIngress({ command: { kind: "approve", taskId: "task-approve" }, message, policy: { enabled: true }, room, localMachineId: "target", isAuthorizedHuman: true, lookup, now: "2026-05-15T00:00:01.000Z" })) + .toMatchObject({ kind: "approve", task: { status: "claimable" } }); + expect(await evaluateDelegationIngress({ command: { kind: "status", taskId: "task-approve" }, message, policy: { enabled: true }, room: { ...room, conversationId: "C2" }, localMachineId: "target", isAuthorizedHuman: true, lookup })) + .toMatchObject({ kind: "reject", message: expect.stringContaining("not visible") }); + }); + + it("claims eligible local tasks and builds bounded task prompts", async () => { + const task = createDelegationTask({ + id: "task-1", + sourceMachineId: "bot-a", + target: { kind: "machine", machineId: "target" }, + goal: "run tests", + room, + expiryMs: 60000, + status: "claimable", + createdAt: "2026-05-15T00:00:00.000Z", + }); + const decision = await evaluateDelegationIngress({ + command: { kind: "claim", taskId: "task-1" }, + message, + policy: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false }, + room, + localMachineId: "target", + localBotUserId: "local-bot", + isAuthorizedHuman: true, + eligibleRoutes: [route()], + lookup: { get: async () => task, list: async () => [task] }, + now: "2026-05-15T00:00:01.000Z", + }); + expect(decision).toMatchObject({ kind: "claim", requiresHuman: false, task: { status: "claimed", claimedBy: { sessionKey: "s1" } } }); + if (decision.kind !== "claim") throw new Error("not claimed"); + expect(decision.prompt).toContain("delegated task task-1"); + expect(buildDelegatedTaskPrompt(task)).not.toContain("TOKEN="); + + const noRouteDecision = await evaluateDelegationIngress({ + command: { kind: "claim", taskId: "task-1" }, + message, + policy: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false }, + room, + localMachineId: "target", + isAuthorizedHuman: true, + eligibleRoutes: [], + lookup: { get: async () => task, list: async () => [task] }, + now: "2026-05-15T00:00:01.000Z", + }); + expect(noRouteDecision).toMatchObject({ kind: "ignore", reason: "not-eligible", message: expect.stringContaining("ambiguous-session") }); + + const peerDecision = await evaluateDelegationIngress({ + command: { kind: "claim", taskId: "task-1" }, + message: botMessage, + policy: { enabled: true, autonomy: "propose-only", trustedPeers: [{ peerId: "bot-a", allowClaim: true, targetMachineIds: ["target"] }] }, + room, + localMachineId: "target", + isAuthorizedHuman: false, + eligibleRoutes: [route()], + lookup: { get: async () => task, list: async () => [task] }, + now: "2026-05-15T00:00:01.000Z", + }); + expect(peerDecision).toMatchObject({ kind: "claim", requiresHuman: true, task: { status: "claimable" } }); + if (peerDecision.kind !== "claim") throw new Error("not claim"); + expect(peerDecision.task).not.toHaveProperty("claimedBy"); + }); + + it("renders status/history and parses platform action ids", async () => { + const task = createDelegationTask({ id: "task-1", sourceMachineId: "bot-a", target: { kind: "machine", machineId: "target" }, goal: "run tests", room, expiryMs: 60000 }); + const lookup = { get: async () => task, list: async () => [task] }; + expect(await evaluateDelegationIngress({ command: { kind: "status", taskId: "task-1" }, message, policy: { enabled: true }, room, localMachineId: "target", isAuthorizedHuman: true, lookup })).toMatchObject({ kind: "status" }); + expect(await evaluateDelegationIngress({ command: { kind: "history" }, message, policy: { enabled: true }, room, localMachineId: "target", isAuthorizedHuman: true, lookup })).toMatchObject({ kind: "history", text: expect.stringContaining("task-1") }); + + const action: ChannelInboundAction = { kind: "action", channel: "discord", updateId: "a1", actionId: "a1", actionData: "pirelay:delegation:claim:task-1", conversation: message.conversation, sender: message.sender }; + expect(delegationCommandFromAction(action)).toEqual({ kind: "claim", taskId: "task-1" }); + expect(delegationCommandFromAction({ ...action, actionData: "pirelay:delegation:approve:task-1" })).toEqual({ kind: "approve", taskId: "task-1" }); + }); + + it("preserves Slack thread ids in room refs", () => { + expect(delegationRoomFromMessage({ ...message, metadata: { threadTs: "1700.1" } }, "default")).toMatchObject({ threadId: "1700.1" }); + }); +}); diff --git a/tests/relay/agent-delegation.test.ts b/tests/relay/agent-delegation.test.ts new file mode 100644 index 0000000..a24ba4b --- /dev/null +++ b/tests/relay/agent-delegation.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { + createDelegationTask, + delegationEventKey, + evaluateDelegationEligibility, + expireDelegationTaskIfNeeded, + generateDelegationTaskId, + markDelegationTaskStaleAfterRestart, + isDelegationDepthAllowed, + isDelegationTaskExpired, + isTrustedDelegationPeer, + nextDelegationDepth, + rememberDelegationEvent, + renderDelegationTaskSummary, + safeDelegationText, + transitionDelegationTask, + withRememberedDelegationEvent, + type DelegationTaskRecord, +} from "../../extensions/relay/core/agent-delegation.js"; + +const now = "2026-05-15T12:00:00.000Z"; +const later = "2026-05-15T12:00:05.000Z"; +const room = { messenger: "discord" as const, instanceId: "default", conversationId: "guild:channel", threadId: "thread-1" }; + +function makeTask(overrides: Partial[0]> = {}): DelegationTaskRecord { + return createDelegationTask({ + sourceMachineId: "source", + sourceMachineLabel: "Source machine", + target: { kind: "machine", machineId: "target", displayName: "Target machine" }, + goal: "Run the focused test suite", + room, + expiryMs: 60_000, + createdAt: now, + ...overrides, + }); +} + +describe("agent delegation domain helpers", () => { + it("creates bounded safe task records", () => { + const task = makeTask({ + id: "task-fixed", + sourceMachineId: "source machine with spaces", + goal: "Deploy with TOKEN=super-secret-value and then report\nback", + constraints: "Keep it short", + redactionPatterns: [String.raw`super-secret-value`], + }); + + expect(task).toMatchObject({ + id: "task-fixed", + status: "proposed", + sourceMachineId: "source-machine-with-spaces", + target: { kind: "machine", machineId: "target" }, + constraints: "Keep it short", + depth: 0, + handledEventIds: [], + }); + expect(task.goal).toContain("[redacted]"); + expect(task.goal).not.toContain("super-secret-value"); + expect(task.audit).toHaveLength(1); + expect(task.audit[0]).toMatchObject({ kind: "created", taskId: "task-fixed" }); + }); + + it("generates compact task ids", () => { + const id = generateDelegationTaskId((size) => Buffer.alloc(size, 255)); + expect(id).toMatch(/^task-[a-z0-9_-]+$/); + }); + + it("transitions through approval, claim, start, and completion once", () => { + const awaiting = makeTask({ status: "awaiting-approval" }); + const approved = transitionDelegationTask(awaiting, { kind: "approve", actor: { kind: "human", id: "u1" } }, later); + expect(approved).toMatchObject({ ok: true, task: { status: "claimable" } }); + if (!approved.ok) throw new Error("approval failed"); + + const claimed = transitionDelegationTask(approved.task, { kind: "claim", claimant: { machineId: "target", sessionKey: "s1", sessionLabel: "tests" } }, later); + expect(claimed).toMatchObject({ ok: true, task: { status: "claimed", claimedBy: { machineId: "target", sessionKey: "s1" } } }); + if (!claimed.ok) throw new Error("claim failed"); + + const duplicateClaim = transitionDelegationTask(claimed.task, { kind: "claim", claimant: { machineId: "other" } }, later); + expect(duplicateClaim).toMatchObject({ ok: false, reason: "already-claimed" }); + + const started = transitionDelegationTask(claimed.task, { kind: "start" }, later); + expect(started).toMatchObject({ ok: true, task: { status: "running", startedAt: later } }); + if (!started.ok) throw new Error("start failed"); + + const completed = transitionDelegationTask(started.task, { kind: "complete", summary: "All tests passed" }, later); + expect(completed).toMatchObject({ ok: true, task: { status: "completed", completedAt: later, lastSafeSummary: "All tests passed" } }); + if (!completed.ok) throw new Error("completion failed"); + + expect(transitionDelegationTask(completed.task, { kind: "cancel" })).toMatchObject({ ok: false, reason: "terminal" }); + }); + + it("rejects invalid and expired transitions", () => { + const task = makeTask({ expiryMs: 1 }); + expect(transitionDelegationTask(task, { kind: "start" }, now)).toMatchObject({ ok: false, reason: "invalid-transition" }); + expect(isDelegationTaskExpired(task, "2026-05-15T12:00:01.000Z")).toBe(true); + expect(transitionDelegationTask(task, { kind: "claim", claimant: { machineId: "target" } }, "2026-05-15T12:00:01.000Z")).toMatchObject({ ok: false, reason: "expired" }); + expect(expireDelegationTaskIfNeeded(task, "2026-05-15T12:00:01.000Z")).toMatchObject({ status: "expired" }); + }); + + it("marks in-flight tasks stale after broker restart without changing terminal tasks", () => { + const task = makeTask(); + const claimed = transitionDelegationTask(task, { kind: "claim", claimant: { machineId: "target" } }, later); + if (!claimed.ok) throw new Error("claim failed"); + expect(markDelegationTaskStaleAfterRestart(claimed.task, later)).toMatchObject({ status: "blocked" }); + const completed = transitionDelegationTask(claimed.task, { kind: "complete", summary: "done" }, later); + if (!completed.ok) throw new Error("complete failed"); + expect(markDelegationTaskStaleAfterRestart(completed.task, later)).toBe(completed.task); + }); + + it("tracks delegation depth and duplicate events", () => { + expect(nextDelegationDepth(undefined)).toBe(0); + expect(nextDelegationDepth(0)).toBe(1); + expect(isDelegationDepthAllowed(1, 1)).toBe(true); + expect(isDelegationDepthAllowed(2, 1)).toBe(false); + expect(delegationEventKey({ taskId: "task-1", action: "claim", eventId: "evt-1" })).toBe("task-1:claim:evt-1"); + + const first = rememberDelegationEvent({ handledEventIds: [] }, "evt-1"); + expect(first).toEqual({ duplicate: false, handledEventIds: ["evt-1"] }); + expect(rememberDelegationEvent(first, "evt-1")).toEqual({ duplicate: true, handledEventIds: ["evt-1"] }); + + const remembered = withRememberedDelegationEvent(makeTask(), "evt-2"); + expect(remembered.duplicate).toBe(false); + expect(remembered.task.handledEventIds).toContain("evt-2"); + }); + + it("formats safe summaries without leaking obvious secrets", () => { + const text = safeDelegationText("Please use ghp_12345678901234567890 and password=hunter2 now", { maxLength: 40 }); + expect(text).toContain("[redacted]"); + expect(text).not.toContain("hunter2"); + expect(text.length).toBeLessThanOrEqual(40); + + const summary = renderDelegationTaskSummary(makeTask({ constraints: "Do not push" })); + expect(summary).toContain("Delegation"); + expect(summary).toContain("Status: proposed"); + expect(summary).toContain("Target machine (target)"); + }); + + it("keeps peer trust separate from human allow-lists and checks room/target scope", () => { + const trustedPeers = [{ + peerId: "bot-a", + allowCreate: true, + allowClaim: false, + messenger: "discord" as const, + instanceId: "default", + conversationIds: ["guild:channel"], + targetMachineIds: ["target"], + }]; + + expect(isTrustedDelegationPeer({ peerId: "bot-a", room, action: "create", target: { kind: "machine", machineId: "target" }, trustedPeers })).toMatchObject({ trusted: true }); + expect(isTrustedDelegationPeer({ peerId: "bot-a", room, action: "claim", target: { kind: "machine", machineId: "target" }, trustedPeers })).toEqual({ trusted: false, reason: "action-denied" }); + expect(isTrustedDelegationPeer({ peerId: "human-allow-listed", room, action: "create", trustedPeers })).toEqual({ trusted: false, reason: "missing-peer" }); + expect(isTrustedDelegationPeer({ peerId: "bot-a", room, action: "create", target: { kind: "machine", machineId: "other" }, trustedPeers })).toEqual({ trusted: false, reason: "target-denied" }); + }); + + it("evaluates local target, capability, disabled, and loop-depth eligibility", () => { + const targeted = makeTask(); + expect(evaluateDelegationEligibility({ task: targeted, localMachineId: "target", autonomy: "auto-claim-targeted" }, now)).toEqual({ eligible: true, reason: "targeted-machine", requiresHuman: false }); + expect(evaluateDelegationEligibility({ task: targeted, localMachineId: "other", autonomy: "auto-claim-targeted" }, now)).toEqual({ eligible: false, reason: "remote-target" }); + expect(evaluateDelegationEligibility({ task: targeted, localMachineId: "target", autonomy: "propose-only" }, now)).toEqual({ eligible: true, reason: "targeted-machine", requiresHuman: true }); + expect(evaluateDelegationEligibility({ task: targeted, localMachineId: "target", autonomy: "off" }, now)).toEqual({ eligible: false, reason: "disabled" }); + + const capability = makeTask({ target: { kind: "capability", capability: "linux-tests" } }); + expect(evaluateDelegationEligibility({ task: capability, localMachineId: "target", localCapabilities: ["linux-tests"], autonomy: "auto-claim-safe-capability" }, now)).toEqual({ eligible: true, reason: "capability-match", requiresHuman: false }); + expect(evaluateDelegationEligibility({ task: capability, localMachineId: "target", localCapabilities: ["browser"], autonomy: "auto-claim-safe-capability" }, now)).toEqual({ eligible: false, reason: "capability-missing" }); + + const child = makeTask({ parentDepth: 1 }); + expect(evaluateDelegationEligibility({ task: child, localMachineId: "target", autonomy: "auto-claim-targeted", maxDepth: 1 }, now)).toEqual({ eligible: false, reason: "depth-exceeded" }); + }); +}); diff --git a/tests/relay/binding-authority.test.ts b/tests/relay/binding-authority.test.ts index 6370c00..20fddba 100644 --- a/tests/relay/binding-authority.test.ts +++ b/tests/relay/binding-authority.test.ts @@ -17,6 +17,9 @@ function emptyState(): TunnelStoreData { activeChannelSelections: {}, trustedRelayUsers: {}, lifecycleNotifications: {}, + delegationTasks: {}, + delegationAudit: [], + delegationHandledEvents: [], }; } diff --git a/tests/relay/config-diagnostics.test.ts b/tests/relay/config-diagnostics.test.ts index 773d3b4..f1ca2a6 100644 --- a/tests/relay/config-diagnostics.test.ts +++ b/tests/relay/config-diagnostics.test.ts @@ -110,4 +110,38 @@ describe("relay diagnostics", () => { expect(rendered).toContain("Duplicate bot/account fingerprint"); expect(rendered).not.toContain("same-token"); }); + + it("reports delegation readiness and unsafe settings without printing secrets", () => { + const config = baseConfig(); + config.messengers = [{ + ref: { kind: "discord", instanceId: "default" }, + enabled: true, + displayName: "discord", + token: "discord-token", + applicationId: "app-1", + allowUserIds: [], + allowGuildIds: [], + sharedRoom: { enabled: true }, + delegation: { + enabled: true, + autonomy: "auto-claim-targeted", + trustedPeers: [{ peerId: "bot-a", displayName: "Bot A", allowCreate: true }], + localCapabilities: ["linux-tests"], + taskExpiryMs: 60000, + runningTimeoutMs: 60000, + maxDepth: 1, + maxVisibleSummaryChars: 320, + maxHistory: 20, + requireHumanApproval: false, + }, + ingressPolicy: { kind: "owner", machineId: "laptop" }, + limits: { maxTextChars: 3900, maxFileBytes: 1, allowedImageMimeTypes: ["image/png"] }, + unsupported: false, + }]; + + const rendered = renderRelayDiagnostics(config); + expect(rendered).toContain("discord: delegation auto-claim-targeted; trusted peers: 1; capabilities: linux-tests"); + expect(rendered).toContain("delegation can auto-claim"); + expect(rendered).not.toContain("discord-token"); + }); }); diff --git a/tests/relay/config-loader.test.ts b/tests/relay/config-loader.test.ts index 38fc45e..061a883 100644 --- a/tests/relay/config-loader.test.ts +++ b/tests/relay/config-loader.test.ts @@ -79,6 +79,73 @@ describe("relay config loader", () => { expect(loaded.messengers[0]?.sharedRoom).toMatchObject({ enabled: true, plainText: "addressed-only", roomHint: "PiRelay" }); }); + it("loads delegation policy disabled by default and explicit trusted peer settings", async () => { + const configPath = await writeConfig({ + relay: { machineId: "laptop", capabilities: ["linux-tests"] }, + messengers: { + discord: { + default: { + enabled: true, + tokenEnv: "DISCORD_TOKEN", + sharedRoom: { enabled: true }, + delegation: { + enabled: true, + autonomy: "auto-claim-targeted", + trustedPeers: [{ peerId: "bot-a", allowCreate: true, targetMachineIds: ["laptop"] }], + localCapabilities: ["browser"], + taskExpiryMs: 120000, + runningTimeoutMs: 120000, + maxDepth: 2, + maxVisibleSummaryChars: 256, + maxHistory: 10, + }, + }, + }, + telegram: { default: { enabled: true, tokenEnv: "TELEGRAM_TOKEN" } }, + }, + }); + + const loaded = await loadRelayConfig({ + configPath, + env: { DISCORD_TOKEN: "discord-token", TELEGRAM_TOKEN: "telegram-token", PI_RELAY_MACHINE_CAPABILITIES: "docs" }, + }); + + const discord = loaded.messengers.find((messenger) => messenger.ref.kind === "discord"); + expect(loaded.relay.capabilities).toEqual(["linux-tests", "docs"]); + expect(discord?.delegation).toMatchObject({ + enabled: true, + autonomy: "auto-claim-targeted", + localCapabilities: ["linux-tests", "docs", "browser"], + taskExpiryMs: 120000, + runningTimeoutMs: 120000, + maxDepth: 2, + maxVisibleSummaryChars: 256, + maxHistory: 10, + }); + expect(discord?.delegation?.trustedPeers).toEqual([{ peerId: "bot-a", allowCreate: true, targetMachineIds: ["laptop"] }]); + + const telegram = loaded.messengers.find((messenger) => messenger.ref.kind === "telegram"); + expect(telegram?.delegation).toMatchObject({ enabled: false, autonomy: "off", trustedPeers: [] }); + }); + + it("does not enable delegation from autonomy without explicit enabled flag", async () => { + const configPath = await writeConfig({ + messengers: { discord: { default: { enabled: true, tokenEnv: "DISCORD_TOKEN", delegation: { autonomy: "auto-claim-targeted" } } } }, + }); + + const loaded = await loadRelayConfig({ configPath, env: { DISCORD_TOKEN: "discord-token" }, supportedMessengers: ["discord"] }); + + expect(loaded.messengers[0]?.delegation).toMatchObject({ enabled: false, autonomy: "auto-claim-targeted" }); + }); + + it("rejects invalid delegation settings", async () => { + const configPath = await writeConfig({ + messengers: { discord: { default: { enabled: true, tokenEnv: "DISCORD_TOKEN", delegation: { enabled: true, autonomy: "free-for-all" } } } }, + }); + + await expect(loadRelayConfig({ configPath, env: { DISCORD_TOKEN: "token" }, supportedMessengers: ["discord"] })).rejects.toThrow(/delegation\.autonomy/); + }); + it("resolves Discord Application ID from applicationId and legacy clientId aliases", async () => { const appPath = await writeConfig({ messengers: { discord: { default: { enabled: true, tokenEnv: "DISCORD_TOKEN", applicationId: "app-id" } } }, diff --git a/tests/relay/delegation-commands.test.ts b/tests/relay/delegation-commands.test.ts new file mode 100644 index 0000000..3d3a7ea --- /dev/null +++ b/tests/relay/delegation-commands.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { createDelegationTask, type DelegationTaskRecord, type DelegationTaskStatus } from "../../extensions/relay/core/agent-delegation.js"; +import { + delegationActionId, + delegationTaskActionButtons, + delegationTaskActionsForStatus, + parseDelegationActionId, + parseDelegationCommand, + parseDelegationInvocation, + platformDelegationActionSurface, + renderDelegationTaskCard, + renderDelegationTaskPresentation, +} from "../../extensions/relay/commands/delegation.js"; + +const task = createDelegationTask({ + id: "task-abc", + sourceMachineId: "source", + sourceMachineLabel: "Source", + target: { kind: "machine", machineId: "target", displayName: "Target" }, + goal: "Run tests", + constraints: "No deploys", + room: { messenger: "discord", instanceId: "default", conversationId: "C1" }, + expiryMs: 60000, + createdAt: "2026-05-15T00:00:00.000Z", +}); + +describe("delegation command parsing and rendering", () => { + it("parses create commands for machine and capability targets", () => { + expect(parseDelegationInvocation("/delegate @target Run tests now")).toEqual({ + kind: "create", + target: { kind: "machine", machineId: "target" }, + goal: "Run tests now", + rawGoal: "Run tests now", + awaitApproval: false, + }); + expect(parseDelegationInvocation("relay propose #linux-tests npm test")).toMatchObject({ + kind: "create", + target: { kind: "capability", capability: "linux-tests" }, + awaitApproval: true, + }); + expect(parseDelegationInvocation("/delegate #" )).toBeUndefined(); + }); + + it("parses task lifecycle controls and history", () => { + expect(parseDelegationCommand("task", "claim task-1")).toEqual({ kind: "claim", taskId: "task-1" }); + expect(parseDelegationCommand("task", "approve task-1")).toEqual({ kind: "approve", taskId: "task-1" }); + expect(parseDelegationCommand("task", "cancel task-1 no longer needed")).toEqual({ kind: "cancel", taskId: "task-1", reason: "no longer needed" }); + expect(parseDelegationCommand("decline", "task-1 busy")).toEqual({ kind: "decline", taskId: "task-1", reason: "busy" }); + expect(parseDelegationCommand("task", "task-1")).toEqual({ kind: "status", taskId: "task-1" }); + expect(parseDelegationCommand("tasks", "")).toEqual({ kind: "history" }); + expect(parseDelegationCommand("unknown", "task-1")).toBeUndefined(); + }); + + it("creates stable action ids and parses them", () => { + expect(delegationActionId("claim", "task-abc")).toBe("pirelay:delegation:claim:task-abc"); + expect(parseDelegationActionId("pirelay:delegation:cancel:task-abc")).toEqual({ kind: "cancel", taskId: "task-abc" }); + expect(parseDelegationActionId("pirelay:delegation:bogus:task-abc")).toBeUndefined(); + }); + + it("renders task cards with structured presentation and bounded text fallbacks", () => { + const card = renderDelegationTaskCard(task, { commandPrefix: "/relay task" }); + expect(card.text).toContain("Delegation task-abc"); + expect(card.text).toContain("Status: Proposed"); + expect(card.text).toContain("Fallback commands:"); + expect(card.text).toContain("/relay task claim task-abc"); + expect(card.presentation.fields).toContainEqual({ label: "Target", value: "Target" }); + expect(card.fallbackText).toContain("/relay task claim task-abc"); + expect(card.accessibilityText).toBe(card.text); + expect(card.actions.map((action) => action.kind)).toEqual(["claim", "decline", "cancel", "status"]); + + const terminal = { ...task, status: "completed" as const }; + expect(delegationTaskActionsForStatus(terminal).map((action) => action.kind)).toEqual(["status"]); + }); + + it("maps delegation status presentation across task lifecycle states", () => { + const cases: Array<[DelegationTaskStatus, string, string]> = [ + ["claimable", "Claimable", "claim"], + ["awaiting-approval", "Awaiting approval", "approve"], + ["running", "Running", "cancel"], + ["completed", "Completed", "status"], + ["blocked", "Blocked", "status"], + ["failed", "Failed", "status"], + ["cancelled", "Cancelled", "status"], + ["declined", "Declined", "status"], + ["expired", "Expired", "status"], + ]; + + for (const [status, label, firstAction] of cases) { + const current: DelegationTaskRecord = { + ...task, + status, + claimedBy: status === "running" ? { machineId: "target", sessionLabel: "Docs", claimedAt: "2026-05-15T00:00:01.000Z" } : undefined, + lastSafeSummary: status === "completed" ? "All done." : undefined, + }; + const presentation = renderDelegationTaskPresentation(current, { commandPrefix: "relay task" }); + expect(presentation.status.label).toBe(label); + expect(presentation.actions.at(0)?.kind).toBe(firstAction); + if (status === "completed") expect(presentation.latest).toEqual({ label: "Result", value: "All done." }); + if (status === "running") expect(presentation.fields).toContainEqual({ label: "Claimed by", value: "target/Docs" }); + } + }); + + it("maps delegation task actions to channel buttons", () => { + expect(delegationTaskActionButtons(delegationTaskActionsForStatus(task))).toEqual([[{ + label: "Claim", + actionData: "pirelay:delegation:claim:task-abc", + style: "primary", + }, { + label: "Decline", + actionData: "pirelay:delegation:decline:task-abc", + style: "danger", + }, { + label: "Cancel", + actionData: "pirelay:delegation:cancel:task-abc", + style: "danger", + }, { + label: "Status", + actionData: "pirelay:delegation:status:task-abc", + style: "default", + }]]); + }); + + it("maps platform action fallbacks without changing task semantics", () => { + expect(platformDelegationActionSurface("slack", task).textFallback).toContain("relay task claim task-abc"); + expect(platformDelegationActionSurface("discord", task).textFallback).toContain("relay task claim task-abc"); + expect(platformDelegationActionSurface("telegram", task).textFallback).toContain("/task claim task-abc"); + }); +}); diff --git a/tests/relay/local-disconnect.test.ts b/tests/relay/local-disconnect.test.ts index 39ffbfe..e9b5d4c 100644 --- a/tests/relay/local-disconnect.test.ts +++ b/tests/relay/local-disconnect.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TunnelStateStore } from "../../extensions/relay/state/tunnel-store.js"; import { sessionKeyOf } from "../../extensions/relay/core/utils.js"; import type { TelegramBindingMetadata, TelegramTunnelConfig, TunnelRuntime } from "../../extensions/relay/core/types.js"; @@ -11,9 +11,11 @@ const tempDirs: string[] = []; async function config(): Promise { const stateDir = await mkdtemp(join(tmpdir(), "pirelay-local-disconnect-")); tempDirs.push(stateDir); + const configPath = join(stateDir, "config.json"); + vi.stubEnv("PI_RELAY_CONFIG", configPath); return { botToken: "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", - configPath: join(stateDir, "config.json"), + configPath, stateDir, pairingExpiryMs: 300_000, busyDeliveryMode: "followUp", @@ -94,6 +96,25 @@ function mockPi() { }; } +beforeEach(() => { + for (const name of [ + "PI_RELAY_DISCORD_ENABLED", + "PI_RELAY_DISCORD_BOT_TOKEN", + "PI_RELAY_DISCORD_APPLICATION_ID", + "PI_RELAY_DISCORD_CLIENT_ID", + "PI_RELAY_SLACK_ENABLED", + "PI_RELAY_SLACK_BOT_TOKEN", + "PI_RELAY_SLACK_SIGNING_SECRET", + "PI_RELAY_SLACK_APP_TOKEN", + "PI_RELAY_SLACK_APP_ID", + "PI_RELAY_SLACK_EVENT_MODE", + "PI_RELAY_SLACK_WORKSPACE_ID", + "PI_RELAY_SLACK_BOT_USER_ID", + "PI_RELAY_SLACK_ALLOW_USER_IDS", + "PI_RELAY_SLACK_ALLOW_CHANNEL_MESSAGES", + ]) vi.stubEnv(name, undefined); +}); + afterEach(async () => { vi.unstubAllEnvs(); vi.doUnmock("../../extensions/relay/adapters/telegram/runtime.js"); @@ -146,7 +167,7 @@ describe("local relay disconnect", () => { expect(notifications.at(-1)?.message).toBe("PiRelay disconnected for this session."); expect(notifications.map((entry) => entry.message).join("\n")).not.toContain("Telegram tunnel disconnected"); - expect(statuses).toContainEqual({ key: "relay", value: "telegram: ready unpaired" }); + expect(statuses).toContainEqual({ key: "relay", value: "tg ◇" }); expect(fakeRuntime.unregisterRoute).toHaveBeenCalledWith(sessionKey); expect(await store.getBindingBySessionKey(sessionKey)).toMatchObject({ status: "revoked" }); expect(await store.getChannelBindingBySessionKey("discord", sessionKey)).toBeUndefined(); diff --git a/tests/relay/remote-command-parity.test.ts b/tests/relay/remote-command-parity.test.ts index a7a18f8..5f7e9f9 100644 --- a/tests/relay/remote-command-parity.test.ts +++ b/tests/relay/remote-command-parity.test.ts @@ -60,7 +60,9 @@ describe("remote command parity metadata", () => { expect(help).toContain("/sessions@"); expect(help).toContain("/use@ "); expect(help).toContain("/to@ "); + expect(help).toContain("/task@"); expect(help).not.toContain("/sessions@bot"); + expect(help).not.toContain("/task@bot"); }); it("keeps Discord native command metadata namespaced around /relay subcommands", () => { diff --git a/tests/relay/requester-file-delivery.test.ts b/tests/relay/requester-file-delivery.test.ts index c8406d7..747dc00 100644 --- a/tests/relay/requester-file-delivery.test.ts +++ b/tests/relay/requester-file-delivery.test.ts @@ -53,6 +53,9 @@ function authorityState(req = requester()): TunnelStoreData { activeChannelSelections: {}, trustedRelayUsers: {}, lifecycleNotifications: {}, + delegationTasks: {}, + delegationAudit: [], + delegationHandledEvents: [], }; } diff --git a/tests/relay/status-line.test.ts b/tests/relay/status-line.test.ts index 02738f9..1dc4b31 100644 --- a/tests/relay/status-line.test.ts +++ b/tests/relay/status-line.test.ts @@ -2,17 +2,27 @@ import { describe, expect, it } from "vitest"; import { formatRelayStatusLine } from "../../extensions/relay/runtime/status-line.js"; describe("relay status line formatting", () => { - it("formats off, ready, and error states", () => { - expect(formatRelayStatusLine({ channel: "slack", configured: false })).toBe("slack: off"); - expect(formatRelayStatusLine({ channel: "slack", configured: true, runtimeStarted: true })).toBe("slack: ready unpaired"); - expect(formatRelayStatusLine({ channel: "slack", configured: true, runtimeStarted: false })).toBe("slack: starting"); - expect(formatRelayStatusLine({ channel: "slack", configured: true, error: "boom\nwith detail" })).toBe("slack error: boom with detail"); + it("formats compact off, unpaired, starting, and error states", () => { + expect(formatRelayStatusLine({ channel: "slack", configured: false })).toBe("sl ○"); + expect(formatRelayStatusLine({ channel: "slack", configured: true, runtimeStarted: true })).toBe("sl ◇"); + expect(formatRelayStatusLine({ channel: "slack", configured: true, runtimeStarted: false })).toBe("sl ◌"); + expect(formatRelayStatusLine({ channel: "slack", configured: true, error: "boom\nwith detail" })).toBe("sl ✖"); }); - it("formats paired and paused states without identifiers", () => { - expect(formatRelayStatusLine({ channel: "telegram", configured: true, runtimeStarted: true, binding: { conversationKind: "private" } })).toBe("telegram: paired dm"); - expect(formatRelayStatusLine({ channel: "discord", configured: true, runtimeStarted: true, binding: { conversationKind: "channel" } })).toBe("discord: paired channel"); - expect(formatRelayStatusLine({ channel: "slack", configured: true, runtimeStarted: true, binding: { paused: true, conversationKind: "im" } })).toBe("slack: paused dm"); - expect(formatRelayStatusLine({ channel: "slack", configured: true, runtimeStarted: true, binding: { conversationKind: "C123456" } })).toBe("slack: paired"); + it("keeps only useful paired/paused details", () => { + expect(formatRelayStatusLine({ channel: "telegram", configured: true, runtimeStarted: true, binding: { conversationKind: "private" } })).toBe("tg ● ✉"); + expect(formatRelayStatusLine({ channel: "discord", configured: true, runtimeStarted: true, binding: { conversationKind: "channel" } })).toBe("dc ● #"); + expect(formatRelayStatusLine({ channel: "slack", configured: true, runtimeStarted: true, binding: { paused: true, conversationKind: "im" } })).toBe("sl Ⅱ ✉"); + expect(formatRelayStatusLine({ channel: "slack", configured: true, runtimeStarted: true, binding: { conversationKind: "C123456" } })).toBe("sl ●"); + }); + + it("applies status tones to the full segment", () => { + const colorize = (tone: string, text: string) => `<${tone}>${text}`; + + expect(formatRelayStatusLine({ channel: "telegram", configured: true, runtimeStarted: true }, { colorize })).toBe("tg ◇"); + expect(formatRelayStatusLine({ channel: "slack", configured: true, runtimeStarted: false }, { colorize })).toBe("sl ◌"); + expect(formatRelayStatusLine({ channel: "discord", configured: true, binding: {} }, { colorize })).toBe("dc ●"); + expect(formatRelayStatusLine({ channel: "slack", configured: true, binding: { paused: true } }, { colorize })).toBe("sl Ⅱ"); + expect(formatRelayStatusLine({ channel: "slack", configured: true, error: "boom" }, { colorize })).toBe("sl ✖"); }); }); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index cff0900..a830115 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -137,6 +137,61 @@ describe("InProcessTunnelRuntime", () => { expect((await store.getSetup())?.botId).toBe(123456); }); + it("does not report Telegram chats as unpaired when state is temporarily unreadable", async () => { + const config = await createRuntimeConfig(); + const store = new TunnelStateStore(config.stateDir); + await writeFile(join(config.stateDir, "state.json"), "{not-json", "utf8"); + const runtime = new InProcessTunnelRuntime(config, store); + const sent: string[] = []; + (runtime as any).api = { + sendPlainText: async (_chatId: number, text: string) => sent.push(text), + sendChatAction: async () => undefined, + }; + + await (runtime as any).processInbound({ + updateId: 1, + messageId: 1, + text: "hello", + chat: { id: 100, type: "private" }, + user: { id: 200, username: "owner" }, + }); + + expect(sent.at(-1)).toBe("Relay state is temporarily unavailable; retry shortly."); + }); + + it("preserves volatile Telegram route binding when state is temporarily unreadable", async () => { + const config = await createRuntimeConfig(); + const store = new TunnelStateStore(config.stateDir); + const binding: TelegramBindingMetadata = { + sessionKey: "session-state-unreadable:/tmp/session-state-unreadable.jsonl", + sessionId: "session-state-unreadable", + sessionFile: "/tmp/session-state-unreadable.jsonl", + sessionLabel: "state-unreadable.jsonl", + chatId: 100, + userId: 200, + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + }; + await writeFile(join(config.stateDir, "state.json"), "{not-json", "utf8"); + const runtime = new InProcessTunnelRuntime(config, store); + const { route } = createRoute(binding); + const statuses: Array<{ key: string; value: string }> = []; + route.actions.setLocalStatus = (key, value) => statuses.push({ key, value }); + route.actions.clearLocalStatus = (key) => statuses.push({ key, value: "" }); + (runtime as any).api = { + getMe: vi.fn(async () => ({ id: 123456, is_bot: true, first_name: "PiRelay", username: "pirelay_bot" })), + getUpdates: vi.fn(async () => []), + sendPlainText: async () => undefined, + }; + (runtime as any).pollLoop = vi.fn(async () => undefined); + + await runtime.registerRoute(route); + await runtime.stop(); + + expect(route.binding).toMatchObject({ chatId: 100, userId: 200 }); + expect(statuses).toContainEqual({ key: "relay-binding-authority", value: "Relay state is unavailable; protected messenger delivery was suppressed." }); + }); + it("registers Telegram command menu after setup and continues when registration fails", async () => { const config = await createRuntimeConfig(); const store = new TunnelStateStore(config.stateDir); @@ -934,7 +989,7 @@ describe("InProcessTunnelRuntime", () => { sends.length = 0; route.notification.lastTurnId = "turn-long"; - route.notification.lastAssistantText = "Long output ".repeat(40); + route.notification.lastAssistantText = "Long output ".repeat(220); await runtime.notifyTurnCompleted(route, "completed"); @@ -943,7 +998,7 @@ describe("InProcessTunnelRuntime", () => { sends.length = 0; const decisionText = [ - `${"Long decision output ".repeat(30)}`, + `${"Long decision output ".repeat(120)}`, "", "Choose:", "1. Review the current diff.", @@ -1984,6 +2039,36 @@ describe("InProcessTunnelRuntime", () => { expect(deliveries).toHaveLength(0); }); + it("includes Telegram bot mentions on delegation cards in group chats", async () => { + const config = await createRuntimeConfig(); + config.delegation = { + enabled: true, + autonomy: "propose-only", + trustedPeers: [{ peerId: "777", allowCreate: true, targetMachineIds: ["local"], conversationIds: ["-1001"] }], + }; + const store = new TunnelStateStore(config.stateDir); + await store.setSetup({ botId: 123456, botUsername: "mini_builder_bot", botDisplayName: "Mini Builder", validatedAt: new Date().toISOString() }); + const runtime = new InProcessTunnelRuntime(config, store); + const sent: string[] = []; + (runtime as any).api = { + sendPlainText: async (_chatId: number, text: string) => sent.push(text), + sendChatAction: async () => undefined, + }; + + await (runtime as any).processInbound({ + updateId: 1, + messageId: 1, + text: "/delegate@mini_builder_bot local run trusted-peer task", + chat: { id: -1001, type: "supergroup" }, + user: { id: 777, username: "peer", isBot: true }, + }); + + const task = (await store.listDelegationTasks({ roomConversationId: "-1001" }))[0]; + expect(task).toBeDefined(); + expect(sent.at(-1)).toContain("/task@mini_builder_bot"); + expect(sent.at(-1)).toContain(`approve ${task!.id}`); + }); + it("keeps Telegram group active selection separate from the private chat binding", async () => { const config = await createRuntimeConfig(); const store = new TunnelStateStore(config.stateDir); diff --git a/tests/slack-adapter.test.ts b/tests/slack-adapter.test.ts index 14bf126..6f17dab 100644 --- a/tests/slack-adapter.test.ts +++ b/tests/slack-adapter.test.ts @@ -55,6 +55,54 @@ describe("SlackChannelAdapter", () => { expect(event!.conversation.kind).toBe("channel"); }); + it("keeps root Slack thread metadata unset for room matching", () => { + const rootEvent = slackEventToChannelEvent({ + type: "message", + channel_type: "channel", + channel: "C1", + user: "U1", + text: "relay delegate laptop run tests", + ts: "171.4", + team: "T1", + thread_ts: "171.4", + }, config); + expect(rootEvent).toMatchObject({ metadata: { teamId: "T1", threadTs: undefined } }); + + const threadedEvent = slackEventToChannelEvent({ + type: "message", + channel_type: "channel", + channel: "C1", + user: "U1", + text: "relay task claim x", + ts: "171.5", + team: "T1", + thread_ts: "171.4", + }, config); + expect(threadedEvent).toMatchObject({ metadata: { teamId: "T1", threadTs: "171.4" } }); + }); + + it("keeps root Slack thread metadata unset for block actions", () => { + const action = slackEnvelopeToChannelEvent({ + type: "block_actions", + channel: { id: "C1" }, + user: { id: "U1", team_id: "T1" }, + actions: [{ value: "full:t:chat" }], + message: { ts: "171.6", thread_ts: "171.6" }, + response_url: "https://hooks.slack.test/response", + }, config); + expect(action).toMatchObject({ metadata: { teamId: "T1", threadTs: undefined } }); + + const threadedAction = slackEnvelopeToChannelEvent({ + type: "block_actions", + channel: { id: "C1" }, + user: { id: "U1", team_id: "T1" }, + actions: [{ value: "full:t:chat" }], + message: { ts: "171.7", thread_ts: "171.4" }, + response_url: "https://hooks.slack.test/response", + }, config); + expect(threadedAction).toMatchObject({ metadata: { teamId: "T1", threadTs: "171.4" } }); + }); + it("chunks text, maps buttons, uploads files, and sends typing fallback", async () => { const postMessage = vi.fn(async (_payload: unknown) => undefined); const uploadFile = vi.fn(async (_payload: unknown) => undefined); @@ -70,9 +118,11 @@ describe("SlackChannelAdapter", () => { await adapter.answerAction(buildSlackActionId({ channelId: "D1", userId: "U1", responseUrl: "https://hooks.slack.test/response" }), { text: "Done" }); await expect(adapter.downloadFile({ id: "F1", kind: "image", metadata: { url: "https://slack.test/file" } })).resolves.toEqual(new Uint8Array([8])); - expect(postMessage).toHaveBeenCalledTimes(4); + expect(postMessage).toHaveBeenCalledTimes(3); expect(postMessage.mock.calls[0]?.[0]).toMatchObject({ channel: "D1", text: "x".repeat(40) }); - expect((postMessage.mock.calls.at(-1)?.[0] as { blocks: Array> }).blocks[0]![0]).toMatchObject({ text: "Full", value: "full:t:chat" }); + expect((postMessage.mock.calls.at(-1)?.[0] as { blocks: Array<{ type: string; elements?: unknown[] }> }).blocks[0]).toMatchObject({ type: "section" }); + expect((postMessage.mock.calls.at(-1)?.[0] as { blocks: Array<{ type: string; elements?: unknown[] }> }).blocks[1]?.elements?.[0]).toMatchObject({ text: "Full", value: "full:t:chat", actionId: "full:t:chat" }); + expect(postMessage.mock.calls.at(-1)?.[0]).not.toMatchObject({ text: "Actions:" }); expect(uploadFile).toHaveBeenCalledWith(expect.objectContaining({ channel: "D1", fileName: "out.md", caption: "Latest output" })); expect(postEphemeral).toHaveBeenCalledWith(expect.objectContaining({ channel: "D1", user: "U1", text: "Pi is working…", threadTs: "thread-1" })); expect(postResponse).toHaveBeenCalledWith("https://hooks.slack.test/response", { text: "Done", ephemeral: true }); diff --git a/tests/slack-live-delegation.test.ts b/tests/slack-live-delegation.test.ts new file mode 100644 index 0000000..a2ac31e --- /dev/null +++ b/tests/slack-live-delegation.test.ts @@ -0,0 +1,292 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + SlackLiveObserver, + SlackLivePiHarness, + assertSlackFinalChannelState, + readSlackLiveSuiteConfig, + runSlackLivePreflight, + slackLiveSecrets, + SlackWebApiClient, + type SlackHistoryMessage, + type SlackLiveSuiteConfig, + type SlackPreflightAppIdentity, +} from "../extensions/relay/testing/slack-live.js"; + +function envTruthy(value: string | undefined): boolean { + return value === "1" || value === "true" || value === "yes"; +} + +const parsed = readSlackLiveSuiteConfig(); +const describeLive = parsed.ready && parsed.config.delegation?.enabled ? describe : describe.skip; +const useManualDelegationTrigger = envTruthy(process.env.PI_RELAY_SLACK_LIVE_DELEGATION_MANUAL); +let harness: SlackLivePiHarness | undefined; + +afterEach(async () => { + await harness?.stop(); + harness = undefined; +}); + +const liveDelegationTimeoutMs = parsed.ready ? parsed.config.timeoutMs + 30_000 : 330_000; + +describeLive("Slack live delegation integration", () => { + it("creates and claims delegation tasks in a shared room", async () => { + if (!parsed.ready || !parsed.config.delegation) { + throw new Error("Live delegation Slack config is not enabled."); + } + + const config = parsed.config; + const client = new SlackWebApiClient(); + const observer = new SlackLiveObserver(slackLiveSecrets(config)); + + const preflight = await runSlackLivePreflight(config, client); + expect(preflight.ok).toBe(true); + expect(preflight.appIdentities).toHaveLength(2); + + const delegatedConfig = withDelegationFriendlyMachineNames(config, preflight.appIdentities); + harness = new SlackLivePiHarness(delegatedConfig); + await harness.start(); + + const [targetBot, nonTargetBot] = preflight.appIdentities; + expect(targetBot).toBeDefined(); + expect(nonTargetBot).toBeDefined(); + + const runId = process.env.PI_RELAY_SLACK_LIVE_DELEGATION_RUN_ID?.trim() || `pirelay-slack-live-delegation-${Date.now()}`; + const commandTargetCandidates = delegationMachineTargets(targetBot); + const commandTextTarget = commandTargetCandidates[0] ?? targetBot.instanceId; + const commandTargetLabel = `${commandTextTarget} (${targetBot.displayName || targetBot.userId})`; + const delegationCommands = commandTextTarget ? buildDelegationCreateCommands(commandTextTarget, runId) : []; + const oldest = slackTimestampFromMillis(Date.now() - 1_500); + + if (useManualDelegationTrigger) { + console.log("\n[Slack live delegation manual mode]\n"); + console.log(`Target bot for delegation: ${commandTargetLabel}`); + console.log(`Target bot user ID: ${targetBot.userId}`); + console.log(`Channel: ${config.channelId}`); + console.log("Send one of these command forms as a user message:"); + for (const command of delegationCommands) { + console.log(` ${command}`); + } + const createdByManualTrigger = await pollUntil(async () => { + const history = await observer.pollChannelHistory(client, config.driverToken, config.channelId, { + oldest, + limit: 200, + }); + return findDelegationCreateCommand(history, delegationCommands, runId) ? history : undefined; + }, config.timeoutMs, "manual delegation create command"); + expect(createdByManualTrigger.length).toBeGreaterThan(0); + } else { + await client.postMessage(config.driverToken, { + channel: config.channelId, + text: delegationCommands[0] ?? `relay delegate ${commandTextTarget} run delegation smoke check ${runId}`, + }); + } + + const createHistory = await pollUntil(async () => { + const history = await observer.pollChannelHistory(client, config.driverToken, config.channelId, { + oldest, + limit: 120, + }); + const taskId = findDelegationTaskId(history, targetBot.userId, runId); + return taskId ? history : undefined; + }, config.timeoutMs, "delegation task card"); + + const taskMessage = findDelegationTaskMessage(createHistory, targetBot.userId, runId); + const taskId = taskMessage?.id; + const taskCardTs = taskMessage?.ts; + expect(taskId).toBeDefined(); + expect(taskCardTs).toBeDefined(); + + if (useManualDelegationTrigger) { + console.log("\n[Slack live delegation manual mode]\n"); + console.log(`Target task id: ${taskId}`); + console.log("Once the task card appears, either click the Slack Claim button or send this fallback command as a normal message:"); + console.log(" relay task claim "); + console.log(` (use the task id from the card: ${taskId}; do not use a leading slash in Slack)`); + } + + const claimCommand = `relay task claim ${taskId}`; + let claimTs: string | undefined; + if (useManualDelegationTrigger) { + console.log(` ${claimCommand}`); + const claimObserved = await pollUntil(async () => { + const history = await observer.pollChannelHistory(client, config.driverToken, config.channelId, { + oldest, + limit: 240, + }); + const commandTs = findDelegationClaimCommandTs(history, claimCommand); + if (commandTs) return { history, observedAfterTs: commandTs }; + const targetMessages = extractMessages(history, targetBot.userId); + const buttonDrivenUpdate = targetMessages.find((message) => + messageTextIncludes(message, taskId!) + && isExpectedPostClaimDelegationStatus(message, taskCardTs, false, runId) + ); + return buttonDrivenUpdate ? { history, observedAfterTs: taskCardTs } : undefined; + }, config.timeoutMs, "manual delegation claim command or Slack Claim button action"); + expect(claimObserved.history.length).toBeGreaterThan(0); + claimTs = claimObserved.observedAfterTs; + } else { + const ack = await client.postMessage(config.driverToken, { + channel: config.channelId, + text: claimCommand, + }); + claimTs = ack.ts; + } + + const claimedHistory = await pollUntil(async () => { + const history = await observer.pollChannelHistory(client, config.driverToken, config.channelId, { + oldest: claimTs ?? oldest, + limit: 200, + }); + const targetMessages = extractMessages(history, targetBot.userId); + const matched = targetMessages.some((message) => + messageTextIncludes(message, taskId!) + && isExpectedPostClaimDelegationStatus(message, claimTs, config.realAgent, runId) + ); + return matched ? history : undefined; + }, config.timeoutMs, config.realAgent ? "delegation completed task update with result after claim command" : "delegation claimed/running task update after claim command"); + + const snapshot = observer.snapshot(); + const nonTargetMessages = snapshot.flatMap((entry) => { + if (entry.kind !== "message") return [] as SlackHistoryMessage[]; + if (!isSlackHistoryMessage(entry.payload)) return [] as SlackHistoryMessage[]; + return entry.payload.user === nonTargetBot.userId ? [entry.payload] : []; + }); + expect(nonTargetMessages.some((message) => messageTextIncludes(message, taskId!))).toBe(false); + + const finalState = assertSlackFinalChannelState(snapshot, { + runId: taskId!, + requiredText: [taskId!], + forbiddenBotUserIds: [nonTargetBot.userId], + forbiddenText: config.realAgent ? ["PiRelay Slack stub received"] : undefined, + }); + + expect(claimedHistory.at(-1)).toBeDefined(); + expect(finalState).toEqual({ ok: true, failures: [] }); + }, liveDelegationTimeoutMs); +}); + +async function pollUntil(operation: () => Promise, timeoutMs: number, description: string): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await operation(); + if (value !== undefined) return value; + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + throw new Error(`Timed out waiting for Slack live delegation observation: ${description}.`); +} + +function withDelegationFriendlyMachineNames(config: SlackLiveSuiteConfig, appIdentities: readonly SlackPreflightAppIdentity[]): SlackLiveSuiteConfig { + const [firstApp, secondApp] = config.apps; + return { + ...config, + apps: [ + withDelegationFriendlyDisplayName(firstApp, appIdentities), + withDelegationFriendlyDisplayName(secondApp, appIdentities), + ], + }; +} + +function withDelegationFriendlyDisplayName(app: SlackLiveSuiteConfig["apps"][number], appIdentities: readonly SlackPreflightAppIdentity[]): SlackLiveSuiteConfig["apps"][number] { + const appIdentity = appIdentities.find((identity) => identity.instanceId === app.instanceId); + if (!appIdentity || appIdentity.displayName === app.displayName) return app; + return { ...app, displayName: appIdentity.displayName }; +} + +function delegationMachineTargets(identity: SlackPreflightAppIdentity): string[] { + return [...new Set([identity.displayName, identity.userName, identity.userId, identity.instanceId] + .map((value) => normalizeMachineTarget(value ?? "")) + .filter((value): value is string => value.length > 0))]; +} + +function buildDelegationCreateCommands(machineTarget: string, runId: string): string[] { + const normalizedTarget = normalizeMachineTarget(machineTarget); + if (!normalizedTarget) return []; + const targets = [ + normalizedTarget, + `machine:${normalizedTarget}`, + `@${normalizedTarget}`, + `machine:@${normalizedTarget}`, + ]; + return [...new Set(targets)].map((target) => `relay delegate ${target} run delegation smoke check ${runId}`); +} + +function normalizeMachineTarget(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/^@+/, "") + .replace(/[^a-zA-Z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 128); +} + +function normalizeSlackCommandTextForMatch(text: string): string { + const withMentionsUnpacked = text.replace(/<@([A-Z0-9_]+)>/g, "@$1"); + return withMentionsUnpacked.trim().toLowerCase(); +} + +function extractMessages(history: readonly SlackHistoryMessage[], userId: string): SlackHistoryMessage[] { + return history.filter((message) => message.user === userId); +} + +function findDelegationCreateCommand(history: readonly SlackHistoryMessage[], expectedTexts: string[], expectedRunId?: string): boolean { + const normalizedRunId = expectedRunId ? normalizeSlackCommandTextForMatch(expectedRunId) : undefined; + return history.some((message) => { + if (message.user === undefined || message.botId !== undefined) return false; + const text = message.text ?? ""; + const normalized = normalizeSlackCommandTextForMatch(text); + const withoutSlash = normalized.replace(/^\//, ""); + if (normalizedRunId && normalizedRunId.length > 0 && normalized.includes(normalizedRunId)) return true; + return expectedTexts.some((expectedText) => { + const expected = normalizeSlackCommandTextForMatch(expectedText); + return withoutSlash.includes(expected) || normalized.includes(expected) || withoutSlash.includes(`relay delegate ${expected}`); + }); + }); +} + +function findDelegationClaimCommandTs(history: readonly SlackHistoryMessage[], expectedPrefix: string): string | undefined { + const normalizedPrefix = normalizeSlackCommandTextForMatch(expectedPrefix); + const expectedTaskPrefix = expectedPrefix.replace(/^relay\s+/i, ""); + const normalizedTaskPrefix = normalizeSlackCommandTextForMatch(expectedTaskPrefix); + return history.find((message) => { + if (message.user === undefined || message.botId !== undefined) return false; + const normalized = normalizeSlackCommandTextForMatch(message.text ?? ""); + const normalizedWithoutSlash = normalized.replace(/^\//, ""); + return normalized.includes(normalizedPrefix) + || normalizedWithoutSlash.includes(normalizedPrefix) + || normalized.includes(`task claim ${normalizedTaskPrefix}`) + || normalizedWithoutSlash.includes(`task claim ${normalizedTaskPrefix}`); + })?.ts; +} + +function findDelegationTaskId(history: readonly SlackHistoryMessage[], botUserId: string, marker: string): string | undefined { + return findDelegationTaskMessage(history, botUserId, marker)?.id; +} + +function findDelegationTaskMessage(history: readonly SlackHistoryMessage[], botUserId: string, marker: string): { id: string; ts: string } | undefined { + return extractMessages(history, botUserId) + .filter((message) => messageTextIncludes(message, marker)) + .flatMap((message) => { + const taskId = message.text?.match(/relay task claim ([a-z0-9_-]+)/i)?.[1]; + return taskId ? [{ id: taskId, ts: message.ts }] : []; + })[0]; +} + +function isExpectedPostClaimDelegationStatus(message: SlackHistoryMessage, claimTs: string | undefined, requireCompletedResult: boolean, runId: string): boolean { + if (claimTs && Number(message.ts) <= Number(claimTs)) return false; + const text = message.text ?? ""; + if (requireCompletedResult) return /Status:\s*completed/i.test(text) && /Latest:/i.test(text) && text.includes(runId); + return /Status:\s*(claimed|running|completed|blocked|failed)/i.test(text) || /Claimed by:/i.test(text); +} + +function messageTextIncludes(message: SlackHistoryMessage, fragment: string): boolean { + return typeof message.text === "string" && message.text.includes(fragment); +} + +function isSlackHistoryMessage(value: unknown): value is SlackHistoryMessage { + return typeof value === "object" && value !== null && typeof (value as SlackHistoryMessage).ts === "string"; +} + +function slackTimestampFromMillis(ms: number): string { + return (ms / 1_000).toFixed(6); +} diff --git a/tests/slack-live.test.ts b/tests/slack-live.test.ts index 982ba94..8ad59f3 100644 --- a/tests/slack-live.test.ts +++ b/tests/slack-live.test.ts @@ -102,6 +102,7 @@ describe("Slack live suite configuration", () => { expect(parsed.config.apps.map((app) => app.instanceId)).toEqual(["slack-live-a", "slack-live-b"]); expect(parsed.config.realAgent).toBe(false); expect(parsed.config.timeoutMs).toBe(42); + expect(parsed.config.delegation).toBeUndefined(); } }); @@ -130,6 +131,36 @@ describe("Slack live suite configuration", () => { expect(slackLiveTargetPrompt({ targetBotUserId: "U_A", runId: "run-1", realAgent: parsed.config.realAgent })).toContain("Reply with exactly this marker"); } }); + + it("parses optional delegated live config and maps settings", () => { + const parsed = readSlackLiveSuiteConfig({ + PI_RELAY_SLACK_LIVE_ENABLED: "true", + PI_RELAY_SLACK_LIVE_WORKSPACE_ID: "T1", + PI_RELAY_SLACK_LIVE_CHANNEL_ID: "C1", + PI_RELAY_SLACK_LIVE_AUTHORIZED_USER_ID: "U_DRIVER", + PI_RELAY_SLACK_LIVE_DRIVER_TOKEN: "xoxp-driver-secret", + PI_RELAY_SLACK_LIVE_BOT_A_TOKEN: "xoxb-a-secret", + PI_RELAY_SLACK_LIVE_BOT_A_SIGNING_SECRET: "signing-a-secret", + PI_RELAY_SLACK_LIVE_BOT_A_APP_TOKEN: "xapp-a-secret", + PI_RELAY_SLACK_LIVE_BOT_A_PI_COMMAND: "pi a", + PI_RELAY_SLACK_LIVE_BOT_B_TOKEN: "xoxb-b-secret", + PI_RELAY_SLACK_LIVE_BOT_B_SIGNING_SECRET: "signing-b-secret", + PI_RELAY_SLACK_LIVE_BOT_B_APP_TOKEN: "xapp-b-secret", + PI_RELAY_SLACK_LIVE_BOT_B_PI_COMMAND: "pi b", + PI_RELAY_SLACK_LIVE_DELEGATION_ENABLED: "true", + PI_RELAY_SLACK_LIVE_DELEGATION_AUTONOMY: "propose-only", + PI_RELAY_SLACK_LIVE_DELEGATION_REQUIRE_HUMAN_APPROVAL: "yes", + }); + + expect(parsed.ready).toBe(true); + if (parsed.ready) { + expect(parsed.config.delegation).toMatchObject({ + enabled: true, + autonomy: "propose-only", + requireHumanApproval: true, + }); + } + }); }); describe("Slack live preflight", () => { @@ -176,6 +207,33 @@ describe("Slack live Pi harness planning", () => { expect(serialized).not.toContain("signing-a-secret"); }); + it("persists delegation settings into generated live bot configs when enabled", () => { + const delegatedConfig: SlackLiveSuiteConfig = { + ...config, + realAgent: true, + delegation: { + enabled: true, + autonomy: "propose-only", + requireHumanApproval: true, + }, + }; + const first = slackLivePiConfig(delegatedConfig, config.apps[0], "/tmp/a"); + + expect(first).toMatchObject({ + messengers: { + slack: { + default: { + delegation: { + enabled: true, + autonomy: "propose-only", + requireHumanApproval: true, + }, + }, + }, + }, + }); + }); + it("launches two isolated child processes and tears down temporary state", async () => { const processConfig: SlackLiveSuiteConfig = { ...config, diff --git a/tests/slack-runtime.test.ts b/tests/slack-runtime.test.ts index 552d6d6..6a491f3 100644 --- a/tests/slack-runtime.test.ts +++ b/tests/slack-runtime.test.ts @@ -173,12 +173,13 @@ describe("SlackLiveOperations", () => { socket.emit("message", { data: "not-json" } as never); socket.emit("message", { data: JSON.stringify({ envelope_id: "env-1", payload: { type: "event_callback", event_id: "ev-1", team_id: "T1", event: { type: "message", channel: "C1", channel_type: "channel", user: "U1", text: "hi", ts: "1" } } }) } as never); socket.emit("message", { data: JSON.stringify({ envelope_id: "env-2", type: "slash_commands", payload: { command: "/relay", text: "status", channel_id: "C1", channel_name: "general", user_id: "U1", user_name: "alice", team_id: "T1", trigger_id: "trigger-1", response_url: "https://hooks.slack.com/commands/T1/B1/response" } }) } as never); - socket.emit("message", { data: JSON.stringify({ envelope_id: "env-3", payload: { type: "block_actions", response_url: "https://hooks.slack.com/actions/T/B/secret", state: { values: "xapp-secret-token" }, token: "xoxb-secret-token", user: { id: "U1" }, channel: { id: "C1" }, actions: [{ value: "summary" }] } }) } as never); + socket.emit("message", { data: JSON.stringify({ envelope_id: "env-3", type: "interactive", payload: { type: "block_actions", response_url: "https://hooks.slack.com/actions/T/B/secret", state: { values: "xapp-secret-token" }, token: "xoxb-secret-token", user: { id: "U1" }, channel: { id: "C1" }, actions: [{ action_id: "summary", value: "summary" }] } }) } as never); await new Promise((resolve) => setTimeout(resolve, 0)); expect(socket.sent).toEqual([JSON.stringify({ envelope_id: "env-1" }), JSON.stringify({ envelope_id: "env-2" }), JSON.stringify({ envelope_id: "env-3" })]); expect(events[0]).toMatchObject({ type: "event_callback", envelopeId: "env-1", eventId: "ev-1", event: { text: "hi", team: "T1" } }); expect(events[1]).toMatchObject({ type: "slash_command", envelopeId: "env-2", command: "/relay", text: "status", channel_id: "C1", user_id: "U1", team_id: "T1", trigger_id: "trigger-1", response_url: "https://hooks.slack.com/commands/T1/B1/response" }); + expect(events[2]).toMatchObject({ type: "block_actions", envelopeId: "env-3", user: { id: "U1" }, channel: { id: "C1" }, actions: [{ action_id: "summary", value: "summary" }] }); const debugLog = await readFile(logPath, "utf8"); expect(debugLog).not.toContain("hooks.slack.com"); expect(debugLog).not.toContain("xapp-secret-token"); @@ -258,8 +259,7 @@ describe("SlackRuntime foundations", () => { await operations.handler!(event); await operations.handler!({ ...event, envelopeId: "env-2", eventId: "ev-2", event: { ...event.event!, user: "U_BOT", bot_id: "B1", ts: "2" } }); - expect(operations.posts).toHaveLength(1); - expect(operations.posts[0]).toMatchObject({ channel: "C1", text: expect.stringContaining("not paired") }); + expect(operations.posts).toHaveLength(0); }); it("prevents overlapping Slack history fallback polls", async () => { @@ -290,6 +290,22 @@ describe("SlackRuntime foundations", () => { vi.useRealTimers(); }); + it("uses configured Slack history oldest timestamp so live startup does not miss manual commands", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + vi.stubEnv("PI_RELAY_SLACK_HISTORY_FALLBACK", "true"); + vi.stubEnv("PI_RELAY_SLACK_HISTORY_OLDEST_TS", "1767225500.123456"); + const operations = new FakeSlackOperations() as FakeSlackOperations & { listChannelMessages: ReturnType }; + operations.listChannelMessages = vi.fn(async () => []); + const runtime = new SlackRuntime(await config(), { operations }); + await runtime.start(); + + await vi.advanceTimersByTimeAsync(2_000); + expect(operations.listChannelMessages).toHaveBeenCalledWith("C1", "1767225500.123456"); + await runtime.stop(); + vi.useRealTimers(); + }); + it("pairs Slack DMs, persists the binding, and restores it for prompt receipt", async () => { const operations = new FakeSlackOperations(); const runtimeConfig = await config(); @@ -812,7 +828,7 @@ describe("SlackRuntime foundations", () => { it("routes Slack channel prompts after pairing, use, and one-shot targeting", async () => { const operations = new FakeSlackOperations(); const runtimeConfig = await config(); - runtimeConfig.slack = { ...runtimeConfig.slack!, allowChannelMessages: true, allowUserIds: ["U_DRIVER", "U_OTHER"] }; + runtimeConfig.slack = { ...runtimeConfig.slack!, allowChannelMessages: true, allowUserIds: ["U_DRIVER", "U_OTHER"], delegation: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false } }; const testRoute = route(); const store = new TunnelStateStore(runtimeConfig.stateDir); const { nonce } = await store.createPendingPairing({ @@ -825,7 +841,10 @@ describe("SlackRuntime foundations", () => { const runtime = new SlackRuntime(runtimeConfig, { operations }); await runtime.registerRoute(testRoute); await runtime.start(); - const sendChannelMessage = async (text: string, ts: string, user = "U_DRIVER") => operations.handler!({ type: "event_callback", envelopeId: `channel-env-${ts}`, eventId: `channel-event-${ts}`, event: { type: "message", channel: "C1", channel_type: "channel", user, text, ts, team: "T1" } }); + const sendChannelMessage = async (text: string, ts: string, user = "U_DRIVER", threadTs?: string) => operations.handler!({ type: "event_callback", envelopeId: `channel-env-${ts}`, eventId: `channel-event-${ts}`, event: { type: "message", channel: "C1", channel_type: "channel", user, text, ts, thread_ts: threadTs, team: "T1" } }); + + await sendChannelMessage("relay delegate local should wait for pairing", "69"); + expect(await store.listDelegationTasks({ roomConversationId: "C1" })).toHaveLength(0); await sendChannelMessage(`relay pair ${nonce}`, "70"); expect(operations.posts.at(-1)).toMatchObject({ channel: "C1", text: expect.stringContaining("Slack paired") }); @@ -834,6 +853,15 @@ describe("SlackRuntime foundations", () => { await sendChannelMessage("ordinary channel prompt after pairing", "70.1"); expect(testRoute.actions.sendUserMessage).toHaveBeenLastCalledWith("ordinary channel prompt after pairing"); + await sendChannelMessage("relay delegate local run channel task", "70.2", "U_DRIVER", "thread-70"); + const [delegationTask] = await store.listDelegationTasks({ roomConversationId: "C1" }); + expect(delegationTask).toMatchObject({ status: "claimable", room: { threadId: "thread-70" } }); + await sendChannelMessage(`relay task claim ${delegationTask!.id}`, "70.3", "U_DRIVER", "thread-70"); + expect(testRoute.actions.sendUserMessage).toHaveBeenLastCalledWith(expect.stringContaining(`delegated task ${delegationTask!.id}`)); + testRoute.notification.lastAssistantText = "Channel task done."; + await runtime.notifyTurnCompleted(testRoute, "completed"); + expect(operations.posts.at(-1)).toMatchObject({ channel: "C1", threadTs: "thread-70", text: expect.stringContaining("Status: Completed") }); + await store.clearActiveChannelSelection("slack", "C1", "U_DRIVER"); const sendCount = vi.mocked(testRoute.actions.sendUserMessage).mock.calls.length; const postCount = operations.posts.length; @@ -877,6 +905,377 @@ describe("SlackRuntime foundations", () => { expect(testRoute.actions.sendUserMessage).toHaveBeenCalledTimes(callsBeforeRemoteTarget); }); + it("allows delegation task claim in the same Slack root thread (without thread metadata)", async () => { + const operations = new FakeSlackOperations(); + const runtimeConfig = await config(); + runtimeConfig.machineId = "laptop"; + runtimeConfig.machineAliases = ["lap"]; + runtimeConfig.machineDisplayName = "Laptop"; + runtimeConfig.slack = { + ...runtimeConfig.slack!, + allowChannelMessages: true, + allowUserIds: ["U_DRIVER", "U_OTHER"], + delegation: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false }, + }; + const testRoute = route(); + const store = new TunnelStateStore(runtimeConfig.stateDir); + const { nonce } = await store.createPendingPairing({ + channel: "slack", + sessionId: testRoute.sessionId, + sessionLabel: testRoute.sessionLabel, + expiryMs: 300_000, + codeKind: "pin", + }); + const runtime = new SlackRuntime(runtimeConfig, { operations }); + await runtime.registerRoute(testRoute); + await runtime.start(); + const sendChannelMessage = async (text: string, ts: string, user = "U_DRIVER", threadTs?: string) => operations.handler!({ + type: "event_callback", + envelopeId: `root-env-${ts}`, + eventId: `root-event-${ts}`, + event: { type: "message", channel: "C1", channel_type: "channel", user, text, ts, thread_ts: threadTs, team: "T1" }, + }); + + await sendChannelMessage(`relay pair ${nonce}`, "1"); + await sendChannelMessage("relay delegate laptop run root thread task", "100"); + const [task] = await store.listDelegationTasks({ roomConversationId: "C1" }); + expect(task).toMatchObject({ status: "claimable", room: { conversationId: "C1" } }); + expect(task?.room.threadId).toBeUndefined(); + + await sendChannelMessage(`relay task claim ${task!.id}`, "101"); + expect(testRoute.actions.sendUserMessage).toHaveBeenLastCalledWith(expect.stringContaining(`delegated task ${task!.id}`)); + }); + + it("acknowledges delegation actions and keeps action responses scoped", async () => { + const operations = new FakeSlackOperations(); + const runtimeConfig = await config(); + runtimeConfig.machineId = "laptop"; + runtimeConfig.machineAliases = ["lap"]; + runtimeConfig.machineDisplayName = "Laptop"; + runtimeConfig.slack = { + ...runtimeConfig.slack!, + allowChannelMessages: true, + allowUserIds: ["U_DRIVER"], + delegation: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false }, + }; + const testRoute = route(); + const store = new TunnelStateStore(runtimeConfig.stateDir); + const { nonce } = await store.createPendingPairing({ + channel: "slack", + sessionId: testRoute.sessionId, + sessionLabel: testRoute.sessionLabel, + expiryMs: 300_000, + codeKind: "pin", + }); + const runtime = new SlackRuntime(runtimeConfig, { operations }); + await runtime.registerRoute(testRoute); + await runtime.start(); + const sendChannelMessage = async (text: string, ts: string, user = "U_DRIVER", threadTs?: string) => operations.handler!({ + type: "event_callback", + envelopeId: `action-env-${ts}`, + eventId: `action-event-${ts}`, + event: { type: "message", channel: "C1", channel_type: "channel", user, text, ts, thread_ts: threadTs, team: "T1" }, + }); + + await sendChannelMessage(`relay pair ${nonce}`, "201"); + await sendChannelMessage("relay delegate laptop run action task", "202"); + const [task] = await store.listDelegationTasks({ roomConversationId: "C1" }); + expect(operations.posts.at(-1)?.text).toContain("Fallback commands:"); + expect(operations.posts.at(-1)?.blocks?.[0]).toMatchObject({ type: "section" }); + expect(operations.posts.at(-1)?.blocks?.[1]).toMatchObject({ elements: expect.arrayContaining([expect.objectContaining({ text: "Claim", value: `pirelay:delegation:claim:${task!.id}`, actionId: `pirelay:delegation:claim:${task!.id}`, style: "primary" })]) }); + + await operations.handler!({ + type: "block_actions", + channel: { id: "C1" }, + user: { id: "U_DRIVER", team_id: "T1" }, + actions: [{ value: `pirelay:delegation:claim:${task!.id}` }], + response_url: "https://hooks.slack.test/delegation-action", + }); + expect(operations.responses.at(-1)).toMatchObject({ + url: "https://hooks.slack.test/delegation-action", + text: "Delegation action handled.", + }); + + await operations.handler!({ + type: "block_actions", + channel: { id: "C1" }, + user: { id: "U_DRIVER", team_id: "T1" }, + actions: [{ value: "pirelay:delegation:approve:missing-task" }], + response_url: "https://hooks.slack.test/delegation-action-stale", + }); + expect(operations.responses.at(-1)).toMatchObject({ + url: "https://hooks.slack.test/delegation-action-stale", + text: "Delegation action was ignored or stale.", + }); + }); + + it("drops Slack peer-bot non-delegation commands before pre-routing side effects", async () => { + const operations = new FakeSlackOperations(); + const runtimeConfig = await config(); + runtimeConfig.slack = { ...runtimeConfig.slack!, allowChannelMessages: true }; + const runtime = new SlackRuntime(runtimeConfig, { operations }); + await runtime.start(); + + await (runtime as unknown as { handleMessage(message: unknown): Promise }).handleMessage({ + kind: "message", + channel: "slack", + updateId: "bot-use-1", + messageId: "bot-use-1", + text: "relay use remote Docs", + attachments: [], + conversation: { channel: "slack", id: "C1", kind: "channel" }, + sender: { channel: "slack", userId: "U_PEER_BOT", metadata: { botId: "B_PEER" } }, + }); + + const store = new TunnelStateStore(runtimeConfig.stateDir); + await expect(store.getActiveChannelSelection("slack", "C1", "U_PEER_BOT")).resolves.toBeUndefined(); + }); + + it("requires Slack capability delegation creation to be source-scoped", async () => { + const operations = new FakeSlackOperations(); + const runtimeConfig = await config(); + runtimeConfig.slack = { + ...runtimeConfig.slack!, + allowChannelMessages: true, + delegation: { enabled: true, autonomy: "auto-claim-safe-capability", requireHumanApproval: false, localCapabilities: ["linux-tests"] }, + }; + const testRoute = route(); + const store = new TunnelStateStore(runtimeConfig.stateDir); + const { nonce } = await store.createPendingPairing({ + channel: "slack", + sessionId: testRoute.sessionId, + sessionLabel: testRoute.sessionLabel, + expiryMs: 300_000, + codeKind: "pin", + }); + const runtime = new SlackRuntime(runtimeConfig, { operations }); + await runtime.registerRoute(testRoute); + await runtime.start(); + const sendChannelMessage = async (text: string, ts: string) => operations.handler!({ + type: "event_callback", + envelopeId: `cap-env-${ts}`, + eventId: `cap-event-${ts}`, + event: { type: "message", channel: "C1", channel_type: "channel", user: "U_DRIVER", text, ts, team: "T1" }, + }); + + await sendChannelMessage(`relay pair ${nonce}`, "301"); + await sendChannelMessage("relay delegate #linux-tests run tests", "302"); + expect(await store.listDelegationTasks({ roomConversationId: "C1" })).toHaveLength(0); + + await sendChannelMessage("<@U_BOT> relay delegate #linux-tests run tests", "303"); + expect(await store.listDelegationTasks({ roomConversationId: "C1" })).toEqual([expect.objectContaining({ target: { kind: "capability", capability: "linux-tests" } })]); + }); + + it("supports live delegation create without prior pairing when preseeded binding mode is enabled", async () => { + const operations = new FakeSlackOperations(); + const runtimeConfig = await config(); + runtimeConfig.machineId = "pirelay__mini_"; + runtimeConfig.machineDisplayName = "pirelay__mini_"; + runtimeConfig.slack = { + ...runtimeConfig.slack!, + allowChannelMessages: true, + delegation: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false }, + sharedRoom: runtimeConfig.slack!.sharedRoom ? { ...runtimeConfig.slack!.sharedRoom, machineAliases: ["pirelay__mini_", "a", "U0B2WTNL3U1"] } : { enabled: true, roomHint: "C1", machineAliases: ["pirelay__mini_", "a", "U0B2WTNL3U1"] }, + }; + const testRoute = route(); + testRoute.sessionLabel = "pirelay__mini_"; + const store = new TunnelStateStore(runtimeConfig.stateDir); + const preseeded = process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING; + process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING = "true"; + + try { + const runtime = new SlackRuntime(runtimeConfig, { operations }); + await runtime.registerRoute(testRoute); + await runtime.start(); + const sendChannelMessage = async (text: string, ts: string) => operations.handler!({ + type: "event_callback", + envelopeId: `preseed-env-${ts}`, + eventId: `preseed-event-${ts}`, + event: { type: "message", channel: "C1", channel_type: "channel", user: "U_DRIVER", text, ts, team: "T1" }, + }); + + await sendChannelMessage("relay delegate pirelay__mini_ run preseeded task", "1000"); + const [task] = await store.listDelegationTasks({ roomConversationId: "C1" }); + expect(task).toMatchObject({ target: { kind: "machine", machineId: "pirelay__mini_" } }); + await expect(store.getActiveChannelSelection("slack", "C1", "U_DRIVER")).resolves.toBeUndefined(); + + await sendChannelMessage("ordinary text should not route after delegation create", "1001"); + expect(testRoute.actions.sendUserMessage).not.toHaveBeenCalledWith("ordinary text should not route after delegation create"); + } finally { + if (preseeded === undefined) { + delete process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING; + } else { + process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING = preseeded; + } + } + }); + + it("ignores Slack shared-room delegation claims for unknown tasks", async () => { + const operations = new FakeSlackOperations(); + const runtimeConfig = await config(); + runtimeConfig.machineId = "pirelay__work_"; + runtimeConfig.machineDisplayName = "pirelay__work_"; + runtimeConfig.slack = { + ...runtimeConfig.slack!, + allowChannelMessages: true, + delegation: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false }, + sharedRoom: runtimeConfig.slack!.sharedRoom + ? { ...runtimeConfig.slack!.sharedRoom, machineAliases: ["pirelay__work_", "b", "U0B3P9D4ECQ"] } + : { enabled: true, roomHint: "C1", machineAliases: ["pirelay__work_", "b", "U0B3P9D4ECQ"] }, + }; + const preseeded = process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING; + process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING = "true"; + + try { + const runtime = new SlackRuntime(runtimeConfig, { operations }); + await runtime.start(); + await operations.handler!({ + type: "event_callback", + envelopeId: "unknown-task-env", + eventId: "unknown-task-event", + event: { type: "message", channel: "C1", channel_type: "channel", user: "U_DRIVER", text: "relay task claim task-owned-by-other", ts: "1500", team: "T1" }, + }); + + expect(operations.posts).toHaveLength(0); + } finally { + if (preseeded === undefined) { + delete process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING; + } else { + process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING = preseeded; + } + } + }); + + it("blocks Slack delegation claims while the target route is busy", async () => { + const operations = new FakeSlackOperations(); + const runtimeConfig = await config(); + runtimeConfig.machineId = "pirelay__mini_"; + runtimeConfig.machineDisplayName = "pirelay__mini_"; + runtimeConfig.slack = { + ...runtimeConfig.slack!, + allowChannelMessages: true, + delegation: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false }, + sharedRoom: runtimeConfig.slack!.sharedRoom ? { ...runtimeConfig.slack!.sharedRoom, machineAliases: ["pirelay__mini_"] } : { enabled: true, roomHint: "C1", machineAliases: ["pirelay__mini_"] }, + }; + const testRoute = route(); + testRoute.notification.lastStatus = "running"; + testRoute.actions.context = { isIdle: () => false } as never; + const store = new TunnelStateStore(runtimeConfig.stateDir); + const preseeded = process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING; + process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING = "true"; + + try { + const runtime = new SlackRuntime(runtimeConfig, { operations }); + await runtime.registerRoute(testRoute); + await runtime.start(); + const sendChannelMessage = async (text: string, ts: string) => operations.handler!({ + type: "event_callback", + envelopeId: `busy-delegation-env-${ts}`, + eventId: `busy-delegation-event-${ts}`, + event: { type: "message", channel: "C1", channel_type: "channel", user: "U_DRIVER", text, ts, team: "T1" }, + }); + + await sendChannelMessage("relay delegate pirelay__mini_ run busy task", "1800"); + const [task] = await store.listDelegationTasks({ roomConversationId: "C1" }); + await sendChannelMessage(`relay task claim ${task!.id}`, "1801"); + + expect(testRoute.actions.sendUserMessage).not.toHaveBeenCalledWith(expect.stringContaining(`delegated task ${task!.id}`)); + await expect(store.getDelegationTask(task!.id)).resolves.toMatchObject({ status: "blocked" }); + expect(operations.posts.at(-1)?.text).toContain("Status: Blocked"); + testRoute.notification.lastAssistantText = "Unrelated running turn completed."; + await runtime.notifyTurnCompleted(testRoute, "completed"); + await expect(store.getDelegationTask(task!.id)).resolves.toMatchObject({ status: "blocked" }); + } finally { + if (preseeded === undefined) { + delete process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING; + } else { + process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING = preseeded; + } + } + }); + + it("supports delegation create while preseeded and without a registered route", async () => { + const operations = new FakeSlackOperations(); + const runtimeConfig = await config(); + runtimeConfig.machineId = "pirelay__mini_"; + runtimeConfig.machineDisplayName = "pirelay__mini_"; + runtimeConfig.slack = { + ...runtimeConfig.slack!, + allowChannelMessages: true, + delegation: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false }, + sharedRoom: runtimeConfig.slack!.sharedRoom + ? { ...runtimeConfig.slack!.sharedRoom, machineAliases: ["pirelay__mini_", "a", "U0B2WTNL3U1"] } + : { enabled: true, roomHint: "C1", machineAliases: ["pirelay__mini_", "a", "U0B2WTNL3U1"] }, + }; + const store = new TunnelStateStore(runtimeConfig.stateDir); + const preseeded = process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING; + process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING = "true"; + + try { + const runtime = new SlackRuntime(runtimeConfig, { operations }); + await runtime.start(); + const sendChannelMessage = async (text: string, ts: string) => operations.handler!({ + type: "event_callback", + envelopeId: `preseed-env-${ts}`, + eventId: `preseed-event-${ts}`, + event: { type: "message", channel: "C1", channel_type: "channel", user: "U_DRIVER", text, ts, team: "T1" }, + }); + + await sendChannelMessage("relay delegate pirelay__mini_ run unbound preseed task", "2000"); + const [task] = await store.listDelegationTasks({ roomConversationId: "C1" }); + expect(task).toMatchObject({ target: { kind: "machine", machineId: "pirelay__mini_" } }); + await sendChannelMessage(`relay task claim ${task!.id}`, "2001"); + expect(await store.getDelegationTask(task!.id)).toMatchObject({ status: "blocked" }); + expect(operations.posts.at(-1)?.text).toContain("Status: Blocked"); + } finally { + if (preseeded === undefined) { + delete process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING; + } else { + process.env.PI_RELAY_SLACK_LIVE_PRESEEDED_BINDING = preseeded; + } + } + }); + + it("blocks Slack delegation tasks when prompt handoff fails after claim", async () => { + const operations = new FakeSlackOperations(); + const runtimeConfig = await config(); + runtimeConfig.machineId = "laptop"; + runtimeConfig.slack = { + ...runtimeConfig.slack!, + allowChannelMessages: true, + delegation: { enabled: true, autonomy: "auto-claim-targeted", requireHumanApproval: false }, + }; + const testRoute = route(); + vi.mocked(testRoute.actions.sendUserMessage).mockImplementation(() => { + throw new Error("send failed"); + }); + const store = new TunnelStateStore(runtimeConfig.stateDir); + const { nonce } = await store.createPendingPairing({ + channel: "slack", + sessionId: testRoute.sessionId, + sessionLabel: testRoute.sessionLabel, + expiryMs: 300_000, + codeKind: "pin", + }); + const runtime = new SlackRuntime(runtimeConfig, { operations }); + await runtime.registerRoute(testRoute); + await runtime.start(); + const sendChannelMessage = async (text: string, ts: string) => operations.handler!({ + type: "event_callback", + envelopeId: `fail-env-${ts}`, + eventId: `fail-event-${ts}`, + event: { type: "message", channel: "C1", channel_type: "channel", user: "U_DRIVER", text, ts, team: "T1" }, + }); + + await sendChannelMessage(`relay pair ${nonce}`, "401"); + await sendChannelMessage("relay delegate laptop run failing handoff", "402"); + const [task] = await store.listDelegationTasks({ roomConversationId: "C1" }); + await sendChannelMessage(`relay task claim ${task!.id}`, "403"); + + expect(await store.getDelegationTask(task!.id)).toMatchObject({ status: "blocked" }); + expect(operations.posts.at(-1)?.text).toContain("Status: Blocked"); + }); + it("routes Slack shared-room messages only when locally targeted or actively selected", async () => { const operations = new FakeSlackOperations(); const runtimeConfig = await config(); diff --git a/tests/state-store.test.ts b/tests/state-store.test.ts index bae4781..dff0b7f 100644 --- a/tests/state-store.test.ts +++ b/tests/state-store.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { createDelegationTask, transitionDelegationTask } from "../extensions/relay/core/agent-delegation.js"; import { TunnelStateStore } from "../extensions/relay/state/tunnel-store.js"; const tempDirs: string[] = []; @@ -85,6 +86,104 @@ describe("TunnelStateStore", () => { expect(await store.load()).toMatchObject({ bindings: {}, channelBindings: {} }); }); + it("loads old state files with empty delegation state and stores bounded delegation history", async () => { + const { store, dir } = await createStoreWithDir(); + await writeFile(join(dir, "state.json"), JSON.stringify({ bindings: {}, channelBindings: {} }), { mode: 0o600 }); + expect(await store.load()).toMatchObject({ delegationTasks: {}, delegationAudit: [] }); + + const task = createDelegationTask({ + id: "task-1", + sourceMachineId: "source", + target: { kind: "machine", machineId: "target" }, + goal: "Run tests with TOKEN=secret-value", + redactionPatterns: [String.raw`secret-value`], + room: { messenger: "discord", instanceId: "default", conversationId: "C1" }, + expiryMs: 60000, + createdAt: "2030-05-15T00:00:00.000Z", + }); + + await store.upsertDelegationTask(task, { maxAuditEntries: 1 }); + expect(await store.getDelegationTask("task-1")).toMatchObject({ id: "task-1", goal: "Run tests with [redacted]" }); + expect(await store.listDelegationTasks({ roomConversationId: "C1" })).toHaveLength(1); + expect(await store.listDelegationTasks({ room: { messenger: "discord", instanceId: "default", conversationId: "C1" } })).toHaveLength(1); + expect(await store.listDelegationTasks({ room: { messenger: "slack", instanceId: "default", conversationId: "C1" } })).toHaveLength(0); + expect(await store.rememberDelegationEvent("discord:default:C1:m1:create:new")).toBe(false); + expect(await store.rememberDelegationEvent("discord:default:C1:m1:create:new")).toBe(true); + expect(await store.listDelegationAudit()).toHaveLength(1); + await store.upsertDelegationTask(task, { maxAuditEntries: 10 }); + expect(await store.listDelegationAudit()).toHaveLength(1); + expect(JSON.stringify(await store.load())).not.toContain("secret-value"); + }); + + it("marks in-flight delegation tasks stale after restart and expires running timeouts", async () => { + const store = await createStore(); + const task = createDelegationTask({ + id: "task-running", + sourceMachineId: "source", + target: { kind: "machine", machineId: "target" }, + goal: "Run tests", + room: { messenger: "discord", instanceId: "default", conversationId: "C1" }, + expiryMs: 60000, + createdAt: "2030-05-15T00:00:00.000Z", + }); + await store.upsertDelegationTask({ ...task, status: "running" }); + + const changed = await store.markInFlightDelegationTasksStaleAfterRestart("2030-05-15T00:00:30.000Z"); + expect(changed).toHaveLength(1); + expect(await store.getDelegationTask("task-running")).toMatchObject({ status: "blocked" }); + expect(await store.listDelegationAudit({ taskId: "task-running" })).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "blocked" })])); + + const timeoutTask = createDelegationTask({ + id: "task-timeout", + sourceMachineId: "source", + target: { kind: "machine", machineId: "target" }, + goal: "Run tests", + room: { messenger: "discord", instanceId: "default", conversationId: "C1" }, + expiryMs: 600000, + createdAt: "2030-05-15T00:00:00.000Z", + }); + await store.upsertDelegationTask({ ...timeoutTask, status: "running", startedAt: "2030-05-15T00:00:00.000Z" }); + expect(await store.getDelegationTask("task-timeout", { runningTimeoutMs: 60_000, now: "2030-05-15T00:02:00.000Z" })).toMatchObject({ status: "expired" }); + }); + + it("expires pending delegation tasks on read and rejects stale lifecycle writes", async () => { + const store = await createStore(); + const task = createDelegationTask({ + id: "task-race", + sourceMachineId: "source", + target: { kind: "machine", machineId: "target" }, + goal: "Run tests", + room: { messenger: "discord", instanceId: "default", conversationId: "C1" }, + expiryMs: 60_000, + createdAt: "2026-05-15T00:00:00.000Z", + }); + await store.upsertDelegationTask(task); + + expect(await store.getDelegationTask("task-race", { now: "2026-05-15T00:02:00.000Z" })).toMatchObject({ status: "expired" }); + expect(await store.listDelegationTasks({ roomConversationId: "C1", now: "2026-05-15T00:02:00.000Z" })).toEqual([expect.objectContaining({ status: "expired" })]); + + const fresh = createDelegationTask({ + id: "task-claim-race", + sourceMachineId: "source", + target: { kind: "machine", machineId: "target" }, + goal: "Run tests", + room: { messenger: "discord", instanceId: "default", conversationId: "C1" }, + expiryMs: 60_000, + createdAt: "2030-05-15T00:00:00.000Z", + }); + await store.upsertDelegationTask(fresh); + const firstClaim = transitionDelegationTask(fresh, { kind: "claim", claimant: { machineId: "target", sessionKey: "s1" } }, "2030-05-15T00:00:01.000Z"); + const secondClaim = transitionDelegationTask(fresh, { kind: "claim", claimant: { machineId: "target", sessionKey: "s2" } }, "2030-05-15T00:00:02.000Z"); + expect(firstClaim.ok).toBe(true); + expect(secondClaim.ok).toBe(true); + + const first = await store.tryUpsertDelegationTask(firstClaim.ok ? firstClaim.task : fresh); + const second = await store.tryUpsertDelegationTask(secondClaim.ok ? secondClaim.task : fresh); + expect(first.applied).toBe(true); + expect(second).toMatchObject({ applied: false, reason: "conflict", task: { claimedBy: { sessionKey: "s1" } } }); + expect(await store.getDelegationTask("task-claim-race")).toMatchObject({ claimedBy: { sessionKey: "s1" } }); + }); + it("serializes concurrent state updates so messenger bindings are not clobbered", async () => { const { store, dir } = await createStoreWithDir(); const sameDirStore = new TunnelStateStore(dir); diff --git a/tests/telegram-actions.test.ts b/tests/telegram-actions.test.ts index 1605c14..e6b071f 100644 --- a/tests/telegram-actions.test.ts +++ b/tests/telegram-actions.test.ts @@ -101,7 +101,7 @@ describe("telegram action callbacks", () => { it("only offers full-output actions when the inline summary is truncated", () => { expect(shouldOfferFullOutputActions("Hey! Morning — ready when you are.")).toBe(false); - expect(shouldOfferFullOutputActions(`${"x".repeat(320)}`)).toBe(false); - expect(shouldOfferFullOutputActions(`${"x".repeat(321)}`)).toBe(true); + expect(shouldOfferFullOutputActions(`${"x".repeat(2_000)}`)).toBe(false); + expect(shouldOfferFullOutputActions(`${"x".repeat(2_001)}`)).toBe(true); }); }); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1d11b5b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + fileParallelism: false, + testTimeout: 10_000, + }, +});