Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ Then invite the app to the channel and pair in that channel/thread with `relay p
| select session | `/use <session>` | `relay use <session>` | `/relay use <session>` |
| forget offline session | `/forget <session>` | `relay forget <session>` | `/relay forget <session>` |
| one-shot prompt | `/to <session> <prompt>` | `relay to <session> <prompt>` | `/relay to <session> <prompt>` |
| start replacement session | `/new` | `relay new` | `/relay new` |
| progress mode | `/progress <mode>` | `relay progress <mode>` | `/relay progress <mode>` |
| current progress mode | `/progress` | `relay progress` | `/relay progress` |
| alias current session | `/alias <name\|clear>` | `relay alias <name\|clear>` | `/relay alias <name\|clear>` |
Expand All @@ -278,6 +279,8 @@ Then invite the app to the channel and pair in that channel/thread with `relay p

Remote `/disconnect` is scoped to the requesting chat/conversation only: it revokes that Telegram, Discord, or Slack binding and suppresses future session output/buttons there, without disconnecting other messengers that remain paired to the same Pi session. Local `/relay disconnect` is broader and disconnects the current session from all paired messenger bindings.

Remote `/new` starts a replacement session only for the selected online, idle Pi route; it does not start or resume an offline Pi process. Busy routes refuse the request and suggest waiting or using `/abort`. When Pi provides safe command-capable session control, eligible requester bindings and active selections move to the replacement session automatically; unsupported routes return an explicit limitation. Local `/new` uses the same short, strict same-machine/same-workspace handoff and otherwise falls back to normal offline/reconnect behavior.

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 <messenger|messenger:instance|all> <relative-path> [caption]`; remote forms must not include messenger targets such as `all` or `slack`.

Remote skill invocation is opt-in via `skills.enabled`. When enabled, `/skills` lists allowed local Pi skills from the live session command registry, and `/skill <name> [input]` asks Pi to use that skill. If input is omitted, the next message from the same authorized requester is used as skill input; `/cancel` or `/skill cancel` cancels the pending input.
Expand Down
2 changes: 1 addition & 1 deletion docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Inbound messenger events are normalized as messages or actions before relay hand

`/relay` is canonical for local Pi commands. The old `/telegram-tunnel` local namespace has been removed.

Remote messenger adapters expose the same PiRelay command semantics wherever the platform allows text commands, slash commands, buttons, or fallbacks: `/help`, `/status`, `/sessions`, `/use`, `/to`, `/alias`, `/forget`, `/progress`, `/recent`, `/summary`, `/full`, `/skills`, `/skill`, `/cancel`, `/images`, `/send-image`, `/send-file`, `/steer`, `/followup`, `/abort`, `/compact`, `/pause`, `/resume`, and `/disconnect`. Local fan-out file delivery remains local-only via `/relay send-file <messenger|messenger:instance|all> <relative-path> [caption]`; remote `send-file`/`sendfile` requests are requester-scoped, validated against the workspace, and delivered only back to the same authorized conversation/thread. If an adapter cannot perform a command because of a declared capability limit, it should return an explicit limitation instead of falling through to generic unsupported-command help.
Remote messenger adapters expose the same PiRelay command semantics wherever the platform allows text commands, slash commands, buttons, or fallbacks: `/help`, `/status`, `/sessions`, `/use`, `/to`, `/new`, `/alias`, `/forget`, `/progress`, `/recent`, `/summary`, `/full`, `/skills`, `/skill`, `/cancel`, `/images`, `/send-image`, `/send-file`, `/steer`, `/followup`, `/abort`, `/compact`, `/pause`, `/resume`, and `/disconnect`. Remote `/new` targets only the selected online idle route; it never starts an offline Pi process, refuses busy routes, and reports an explicit capability limitation when command-capable session control is unavailable. Local fan-out file delivery remains local-only via `/relay send-file <messenger|messenger:instance|all> <relative-path> [caption]`; remote `send-file`/`sendfile` requests are requester-scoped, validated against the workspace, and delivered only back to the same authorized conversation/thread. If an adapter cannot perform a command because of a declared capability limit, it should return an explicit limitation instead of falling through to generic unsupported-command help.

Discord is special because Discord owns the `/...` application-command UI. The reliable Discord baseline is ordinary DM text with a prefix, for example `relay status`, `relay sessions`, `relay full`, `relay abort`, and `relay pair 123-456` during pairing. PiRelay best-effort syncs one namespaced native `/relay` command with subcommands when Discord application commands are available, but setup should still advertise `relay <command>` first; bare `/status`-style Discord aliases remain best-effort conveniences only when Discord delivers them as message text, and `/start <pin>` is accepted for pairing compatibility.

Expand Down
4 changes: 3 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ Check:
8. Use `/use docs`, then set `/alias phone`, and verify `/sessions` and selectors use the alias while retaining the original label for disambiguation.
9. Use `/progress quiet`, `/progress verbose`, and `/recent` during a run and verify safe progress noise changes without suppressing terminal completion/failure/abort notifications.
10. Reconnect the disconnected session and verify local prompts still work there too.
11. Run local `/new` from a paired idle session and verify the old route unregisters immediately, no false offline notification is sent, the same-workspace replacement receives the binding/active selection, old buttons become stale, and the messenger receives a moved notification.
12. From authorized Telegram, send `/new` to an online idle selected route and verify relay control moves to the replacement session; repeat while busy, offline, ambiguous, paused, and unsupported, and verify no unsafe session replacement or binding mutation occurs.

## 5. Image bridge behavior

Expand Down Expand Up @@ -122,7 +124,7 @@ These adapter foundations are DM-first and use channel-specific credentials/conf
9. Run `/relay connect discord docs`, scan the QR bot profile/DM link when `discord.applicationId` is configured, then DM the bot `relay pair <pin>` before expiry (`/start <pin>` remains a compatibility alias). Confirm the pairing locally in Pi, optionally choosing to trust the Discord user. If the bot cannot be DM'd, check shared-server membership, server member DM privacy settings, and that the bot was already invited with the `bot` scope during setup.
10. After pairing, send `relay status` and `relay sessions` in the Discord DM and verify the same core fields/semantics as Telegram: safe session label, online state, busy state, model, progress mode, last activity, active marker, and no raw session file path or binding storage key. If Discord command sync has completed, also try `/relay status`; bare `/status` and `/sessions` may work when Discord delivers them as text, but smoke tests should not depend on those aliases.
11. Send a normal Discord DM prompt while Pi is idle and verify it reaches the current Pi session and the final Pi completion returns to Discord; repeat while busy and verify the configured busy delivery acknowledgement plus terminal completion/failure/abort notification.
12. Exercise Discord command parity using the reliable prefix forms: `relay use`, `relay to`, `relay alias`, `relay progress`, `relay recent`, `relay summary`, `relay full`, `relay skills`, `relay skill`, `relay cancel`, `relay images`, `relay send-image`, `relay send-file`, `relay steer`, `relay followup`, `relay abort`, `relay compact`, `relay pause`, `relay resume`, and `relay disconnect`; verify commands either work with Telegram-equivalent semantics or return an explicit capability/configuration limitation, not generic unsupported-command help.
12. Exercise Discord command parity using the reliable prefix forms: `relay use`, `relay to`, `relay new`, `relay alias`, `relay progress`, `relay recent`, `relay summary`, `relay full`, `relay skills`, `relay skill`, `relay cancel`, `relay images`, `relay send-image`, `relay send-file`, `relay steer`, `relay followup`, `relay abort`, `relay compact`, `relay pause`, `relay resume`, and `relay disconnect`; verify commands either work with Telegram-equivalent semantics or return an explicit capability/configuration limitation, not generic unsupported-command help.
13. Enable Discord guild-channel control without `allowGuildIds`; verify `/relay doctor` reports an actionable warning/error and `/relay connect discord` refuses pairing until fixed.
14. Run `/relay setup slack` for `eventMode: "socket"` and verify it recommends Socket Mode, requires an app-level token with `connections:write`, exposes a copyable App manifest tab/action, includes interactivity and `/relay`, explains App Home Messages Tab plus `message.im`/`im:history`/`im:read` for DMs, `reactions:write` for thinking indicators, and `files:write` for image/file delivery, and warns when workspace, App ID, or bot-user identity cannot be established; switch to `eventMode: "webhook"` without a signing secret and verify doctor reports the webhook signing requirement.
15. Run `/relay connect slack docs` with enabled mock config and verify the displayed pairing instruction is time-limited, channel-specific, short PIN-style, highlighted in the QR dialog, and copyable with `c`; when `slack.appId` is configured or discovered from `auth.test`, verify the TUI renders a Slack App Home QR/open link and clearly offers both paths: open App Home DM via QR/link or paste the command directly in an invited Slack channel/thread after enabling `slack.allowChannelMessages`.
Expand Down
14 changes: 12 additions & 2 deletions extensions/relay/adapters/discord/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { displayProgressMode, formatProgressUpdate, normalizeProgressMode, progr
import { deliverLiveProgress, type LiveProgressDeliveryState } from "../../notifications/progress-delivery.js";
import { sendFinalOutputWithFallback } from "../../core/final-output.js";
import { formatRelayLifecycleNotification, type RelayLifecycleEventKind } from "../../notifications/lifecycle.js";
import { abortRouteSafely, compactRouteSafely, deliverRoutePrompt, latestRouteImagesSafely, probeRouteAvailability, routeActionDisplayMessage, routeIdleState, routeImageByPathSafely, routeModelState, routeSkillCommandsSafely, routeWorkspaceRootSafely, unavailableRouteMessage } from "../../core/route-actions.js";
import { abortRouteSafely, compactRouteSafely, deliverRoutePrompt, latestRouteImagesSafely, newSessionRouteActionMessage, newSessionRouteSafely, probeRouteAvailability, routeActionDisplayMessage, routeIdleState, routeImageByPathSafely, routeModelState, routeSkillCommandsSafely, routeWorkspaceRootSafely, unavailableRouteMessage } from "../../core/route-actions.js";
import { statusSnapshotForRoute } from "../../core/relay-core.js";
import { authorityOutcomeAllowsDelivery, bindingAuthorityDiagnostic, channelDestinationKey, resolveChannelBindingAuthority } from "../../core/binding-authority.js";
import { redactSecrets } from "../../config/setup.js";
Expand Down Expand Up @@ -902,6 +902,15 @@ export class DiscordRuntime {
case "help":
await this.sendText(message, DISCORD_HELP_TEXT);
return;
case "new": {
const outcome = await newSessionRouteSafely(route, this.discordRequester(route, message));
if (outcome.kind !== "success") {
await this.sendText(message, newSessionRouteActionMessage(outcome));
return;
}
await this.sendText(message, "New Pi session started. Relay control is moving to the replacement session.");
return;
}
case "status":
await this.sendText(message, this.statusTextForRoute(route, binding, true));
return;
Expand Down Expand Up @@ -1587,7 +1596,7 @@ export class DiscordRuntime {
}
}

export type DiscordCommandName = "help" | "status" | "sessions" | "use" | "to" | "progress" | "notify" | "alias" | "forget" | "recent" | "activity" | "summary" | "full" | "skills" | "skill" | "cancel" | "images" | "send-file" | "sendfile" | "send-image" | "sendimage" | "steer" | "followup" | "abort" | "compact" | "pause" | "resume" | "disconnect";
export type DiscordCommandName = "help" | "status" | "sessions" | "use" | "to" | "new" | "progress" | "notify" | "alias" | "forget" | "recent" | "activity" | "summary" | "full" | "skills" | "skill" | "cancel" | "images" | "send-file" | "sendfile" | "send-image" | "sendimage" | "steer" | "followup" | "abort" | "compact" | "pause" | "resume" | "disconnect";

type DiscordCommand = { name: DiscordCommandName; args: string };

Expand All @@ -1597,6 +1606,7 @@ export const DISCORD_SUPPORTED_COMMANDS: readonly DiscordCommandName[] = [
"sessions",
"use",
"to",
"new",
"progress",
"notify",
"alias",
Expand Down
11 changes: 10 additions & 1 deletion extensions/relay/adapters/slack/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { displayProgressMode, formatProgressUpdate, normalizeProgressMode, progr
import { deliverLiveProgress, type LiveProgressDeliveryState } from "../../notifications/progress-delivery.js";
import { sendFinalOutputWithFallback } from "../../core/final-output.js";
import { deliverWorkspaceFileToRequester, formatRequesterFileDeliveryResult, parseRemoteSendFileArgs, type RelayFileDeliveryRequester } from "../../core/requester-file-delivery.js";
import { abortRouteSafely, compactRouteSafely, deliverRoutePrompt, latestRouteImagesSafely, probeRouteAvailability, routeActionDisplayMessage, routeIdleState, routeImageByPathSafely, routeModelState, routeSkillCommandsSafely, routeWorkspaceRootSafely, unavailableRouteMessage } from "../../core/route-actions.js";
import { abortRouteSafely, compactRouteSafely, deliverRoutePrompt, latestRouteImagesSafely, newSessionRouteActionMessage, newSessionRouteSafely, probeRouteAvailability, routeActionDisplayMessage, routeIdleState, routeImageByPathSafely, routeModelState, routeSkillCommandsSafely, routeWorkspaceRootSafely, unavailableRouteMessage } from "../../core/route-actions.js";
import { statusSnapshotForRoute } from "../../core/relay-core.js";
import { authorityOutcomeAllowsDelivery, bindingAuthorityDiagnostic, channelDestinationKey, resolveChannelBindingAuthority } from "../../core/binding-authority.js";
import { formatRelayLifecycleNotification, type RelayLifecycleEventKind } from "../../notifications/lifecycle.js";
Expand Down Expand Up @@ -956,6 +956,15 @@ export class SlackRuntime {
case "help":
await this.sendText(message, SLACK_HELP_TEXT);
return;
case "new": {
const outcome = await newSessionRouteSafely(route, this.slackRequester(route, message));
if (outcome.kind !== "success") {
await this.sendText(message, newSessionRouteActionMessage(outcome));
return;
}
await this.sendText(message, "New Pi session started. Relay control is moving to the replacement session.");
return;
}
case "status": {
const availability = slackRouteAvailability(route);
await this.sendText(message, formatRelayStatusForRoute(route, { online: availability.online, busy: availability.busy, binding }));
Expand Down
11 changes: 10 additions & 1 deletion extensions/relay/adapters/telegram/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { writeFile } from "node:fs/promises";
import lockfile from "proper-lockfile";
import { ensureParentDir, ensureStateDir, getLockFilePath } from "../../state/paths.js";
import { abortRouteSafely, compactRouteSafely, deliverRoutePrompt, latestRouteImagesSafely, probeRouteAvailability, routeActionDisplayMessage, routeIdleState, routeImageByPathSafely, routeModelState, routeSkillCommandsSafely, routeWorkspaceRootSafely, unavailableRouteMessage } from "../../core/route-actions.js";
import { abortRouteSafely, compactRouteSafely, deliverRoutePrompt, latestRouteImagesSafely, newSessionRouteActionMessage, newSessionRouteSafely, probeRouteAvailability, routeActionDisplayMessage, routeIdleState, routeImageByPathSafely, routeModelState, routeSkillCommandsSafely, routeWorkspaceRootSafely, unavailableRouteMessage } from "../../core/route-actions.js";
import { statusSnapshotForRoute } from "../../core/relay-core.js";
import { authorityOutcomeAllowsDelivery, bindingAuthorityDiagnostic, resolveTelegramBindingAuthority, telegramDestinationKey } from "../../core/binding-authority.js";
import { BrokerTunnelRuntime } from "../../broker/tunnel-runtime.js";
Expand Down Expand Up @@ -1849,6 +1849,15 @@ export class InProcessTunnelRuntime implements TunnelRuntime {
}

switch (command) {
case "new": {
const outcome = await newSessionRouteSafely(route, this.telegramRequester(route, message));
if (outcome.kind !== "success") {
await this.api.sendPlainText(message.chat.id, newSessionRouteActionMessage(outcome));
return;
}
await this.api.sendPlainText(message.chat.id, "New Pi session started. Relay control is moving to the replacement session.");
return;
}
case "status": {
await this.sendTextWithKeyboard(message.chat.id, this.statusTextForRoute(route, true), this.dashboardKeyboardForRoute(route));
return;
Expand Down
20 changes: 20 additions & 0 deletions extensions/relay/broker/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -1908,6 +1908,26 @@ async function handleAuthorizedCommand(message, route, command, args) {
}

switch (command) {
case 'new': {
if (isEffectivelyBusy(route)) {
await sendPlainText(message.chat.id, 'Pi is busy. Use /abort or wait for the active work to finish before starting a new session.');
return;
}
try {
const outcome = await requestClient(route, 'newSession', { requester: requesterForMessage(route, message) });
if (outcome?.kind === 'success') {
await sendPlainText(message.chat.id, 'New Pi session started. Relay control is moving to the replacement session.');
return;
}
const text = outcome?.kind === 'failed'
? typeof outcome.safeMessage === 'string' ? outcome.safeMessage : 'Could not start a new Pi session.'
: typeof outcome?.message === 'string' ? outcome.message : 'The Pi session is unavailable. Resume it locally, then try again.';
await sendPlainText(message.chat.id, text);
Comment thread
zikolach marked this conversation as resolved.
} catch {
await sendPlainText(message.chat.id, 'The Pi session became unavailable before a new session could be started. Use /sessions to check its status.');
}
return;
}
case 'status': {
await sendPlainText(message.chat.id, statusTextForRoute(route), dashboardKeyboardForRoute(route));
return;
Expand Down
Loading