Channels: default-only threading + automation (read-only) channels — threading (topic switcher, Ctrl+T, auto-threading) is now only active on the default channel, and the gateway ignores auto_thread for other channels; new channel.set_automation frame marks a channel read-only for cron/webhook output (app hides the composer behind a notice, gateway rejects message.send, default channel cannot be marked, flag syncs to all devices via the full-entry channel.renamed response, gear badge in the list); header pill drops the 'Bot' subtitle and shows the chat_id on automation channels (tap to copy for cron delivery targeting); also fixes a pre-existing stray brace that made frames.schema.json invalid JSON

This commit is contained in:
ARIA committed 2026-08-21 09:39:32 +02:00
1 parent d7cd59b685
commit 10e9565e2e
9 files changed
+251 -32

No files matched your search

@@ -71,6 +71,7 @@ const val TYPE_CHANNEL_RENAME = "channel.rename"
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default" const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
const val TYPE_CHANNEL_FAVORITE = "channel.favorite" const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
const val TYPE_CHANNEL_ICON = "channel.icon" const val TYPE_CHANNEL_ICON = "channel.icon"
const val TYPE_CHANNEL_SET_AUTOMATION = "channel.set_automation"
const val TYPE_CHANNEL_DELETE = "channel.delete" const val TYPE_CHANNEL_DELETE = "channel.delete"
const val TYPE_CHANNEL_CREATED = "channel.created" const val TYPE_CHANNEL_CREATED = "channel.created"
const val TYPE_CHANNEL_RENAMED = "channel.renamed" const val TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -168,6 +169,10 @@ data class ChannelInfo(
/** Cosmetic avatar color override ("#RRGGBB"), or null for the /** Cosmetic avatar color override ("#RRGGBB"), or null for the
* auto-generated name-hash color. */ * auto-generated name-hash color. */
val color: String? = null, val color: String? = null,
/** Automation channel: read-only for the user. It only receives
* gateway-originated output (cron jobs, webhooks); the app hides the
* composer and the gateway rejects direct sends into it. */
val automation: Boolean = false,
) )
@Serializable @Serializable
@@ -339,6 +344,9 @@ data class ChannelRenamePayload(val name: String)
@Serializable @Serializable
data class ChannelFavoritePayload(val on: Boolean) data class ChannelFavoritePayload(val on: Boolean)
@Serializable
data class ChannelSetAutomationPayload(val on: Boolean)
@Serializable @Serializable
data class ChannelIconPayload( data class ChannelIconPayload(
/** base64-encoded image, or null to clear the icon. */ /** base64-encoded image, or null to clear the icon. */
@@ -540,6 +548,17 @@ fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame =
), ),
) )
fun channelSetAutomationFrame(id: Int, chatId: String, on: Boolean): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_SET_AUTOMATION,
chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement(
ChannelSetAutomationPayload.serializer(),
ChannelSetAutomationPayload(on = on),
),
)
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame = fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame =
Frame( Frame(
id = id, id = id,
@@ -59,6 +59,7 @@ import iris.protocol.channelFavoriteFrame
import iris.protocol.channelIconFrame import iris.protocol.channelIconFrame
import iris.protocol.channelListFrame import iris.protocol.channelListFrame
import iris.protocol.channelRenameFrame import iris.protocol.channelRenameFrame
import iris.protocol.channelSetAutomationFrame
import iris.protocol.channelSetDefaultFrame import iris.protocol.channelSetDefaultFrame
import iris.protocol.historyFrame import iris.protocol.historyFrame
import iris.protocol.messageDeleteFrame import iris.protocol.messageDeleteFrame
@@ -553,6 +554,12 @@ class IrisController(
client.sendFrame(channelFavoriteFrame(0, chatId, on)) client.sendFrame(channelFavoriteFrame(0, chatId, on))
} }
/** Mark the channel as automation (read-only; cron/webhook output only).
* The default channel cannot be marked (the server enforces this too). */
fun setChannelAutomation(chatId: String, on: Boolean) {
client.sendFrame(channelSetAutomationFrame(0, chatId, on))
}
/** Set the channel's cosmetic icon (base64 image) and/or avatar color. /** Set the channel's cosmetic icon (base64 image) and/or avatar color.
* Pass null to clear a field. */ * Pass null to clear a field. */
fun setChannelIcon(chatId: String, icon: String?, color: String?) { fun setChannelIcon(chatId: String, icon: String?, color: String?) {
@@ -618,16 +625,21 @@ class IrisController(
) )
} }
chat.addPending(trimmed, lane, media) chat.addPending(trimmed, lane, media)
client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId)) client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId, chatId))
_attachments.value = emptyList() _attachments.value = emptyList()
} }
/** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in a /** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in the
* channel's flat lane gets its own fresh thread, AI-named by the gateway * default channel's flat lane gets its own fresh thread, AI-named by the
* (Telegram topic-mode workflow). Slash commands and media-only sends * gateway (Telegram topic-mode workflow). Threading is only active on
* stay in the flat lane. */ * the default channel; other channels stay flat. Slash commands and
private fun wantsAutoThread(text: String, threadId: String?): Boolean = * media-only sends stay in the flat lane. */
_threadsEnabled.value && threadId == null && text.isNotEmpty() && !text.startsWith("/") private fun wantsAutoThread(text: String, threadId: String?, chatId: String): Boolean =
_threadsEnabled.value &&
threadId == null &&
text.isNotEmpty() &&
!text.startsWith("/") &&
channels.byId(chatId)?.isDefault == true
/** M7: resend a failed user message (tap on the failed bubble). */ /** M7: resend a failed user message (tap on the failed bubble). */
fun retrySend(messageId: String) { fun retrySend(messageId: String) {
@@ -641,7 +653,7 @@ class IrisController(
item.text, item.text,
threadId, threadId,
item.media.map { it.mediaId }, item.media.map { it.mediaId },
autoThread = wantsAutoThread(item.text, threadId), autoThread = wantsAutoThread(item.text, threadId, chatId),
) )
} }
@@ -44,6 +44,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -264,7 +265,11 @@ fun ChatScreen(controller: IrisController) {
showSearch || showPicker || showPalette || showNewChannel || showNewThread || showSettings showSearch || showPicker || showPalette || showNewChannel || showNewThread || showSettings
when { when {
ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true } ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true }
ctrlOrMeta && e.key == Key.T -> { showNewThread = true; true } // Threads are only active on the default channel.
ctrlOrMeta && e.key == Key.T -> {
if (currentChannel?.isDefault == true) showNewThread = true
true
}
ctrlOrMeta && e.key == Key.F -> { showSearch = true; true } ctrlOrMeta && e.key == Key.F -> { showSearch = true; true }
ctrlOrMeta && e.key == Key.K -> { showPalette = true; true } ctrlOrMeta && e.key == Key.K -> { showPalette = true; true }
e.key == Key.Escape && overlayOpen -> { e.key == Key.Escape && overlayOpen -> {
@@ -304,6 +309,11 @@ fun ChatScreen(controller: IrisController) {
selectedIds = setOf(id) selectedIds = setOf(id)
} }
// Automation channels are read-only for the user (cron/webhook output
// only): the composer is replaced by a notice, and the gateway rejects
// direct sends into them anyway.
val isAutomation = currentChannel?.automation == true
fun toggleSelect(id: String) { fun toggleSelect(id: String) {
selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id
if (selectedIds.isEmpty()) selectionMode = false if (selectedIds.isEmpty()) selectionMode = false
@@ -335,7 +345,11 @@ fun ChatScreen(controller: IrisController) {
Text("☰", fontSize = 18.sp) Text("☰", fontSize = 18.sp)
} }
} }
TitlePill(currentChannel, Modifier.weight(1f)) val headerClipboard = LocalClipboardManager.current
TitlePill(currentChannel, Modifier.weight(1f)) { id ->
headerClipboard.setText(AnnotatedString(id))
toastMessage = "Copied $id"
}
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
StatusBubble(state) { toastMessage = statusToastText(state) } StatusBubble(state) { toastMessage = statusToastText(state) }
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
@@ -358,8 +372,9 @@ fun ChatScreen(controller: IrisController) {
} }
} }
// Topic switcher (threads on) // Topic switcher (threads on; the default channel only — other channels
if (threadsEnabled) { // don't use threads).
if (threadsEnabled && currentChannel?.isDefault == true) {
TopicSwitcher( TopicSwitcher(
threads = threads, threads = threads,
currentThreadId = currentThreadId, currentThreadId = currentThreadId,
@@ -469,8 +484,8 @@ fun ChatScreen(controller: IrisController) {
} }
} }
// Pending attachments (M4) // Pending attachments (M4; hidden in automation channels — no composer)
if (attachments.isNotEmpty()) { if (attachments.isNotEmpty() && !isAutomation) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -552,7 +567,28 @@ fun ChatScreen(controller: IrisController) {
isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }) isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null })
val layoutDensity = LocalDensity.current.density val layoutDensity = LocalDensity.current.density
var textHeightPx by remember { mutableFloatStateOf(0f) } var textHeightPx by remember { mutableFloatStateOf(0f) }
if (!selectionMode) Row( if (isAutomation) {
// Read-only notice in place of the composer.
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.panel)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
"⚙ Automation channel — read-only. Hermes posts cron/webhook output here; direct messages are not allowed.",
fontSize = 12.sp,
color = IrisColors.textTertiary,
)
}
}
} else if (!selectionMode) Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp) .padding(horizontal = 12.dp, vertical = 8.dp)
@@ -687,6 +723,7 @@ fun ChatScreen(controller: IrisController) {
onRename = { ch -> channelToRename = ch }, onRename = { ch -> channelToRename = ch },
onToggleFavorite = { ch -> controller.setChannelFavorite(ch.chatId, !ch.favorite) }, onToggleFavorite = { ch -> controller.setChannelFavorite(ch.chatId, !ch.favorite) },
onEditIcon = { ch -> channelToEditIcon = ch }, onEditIcon = { ch -> channelToEditIcon = ch },
onToggleAutomation = { ch -> controller.setChannelAutomation(ch.chatId, !ch.automation) },
onDelete = { ch -> channelToDelete = ch }, onDelete = { ch -> channelToDelete = ch },
onOpenSettings = { showSettings = true }, onOpenSettings = { showSettings = true },
) )
@@ -734,6 +771,10 @@ fun ChatScreen(controller: IrisController) {
channelToEditIcon = ch channelToEditIcon = ch
drawerScope.launch { drawerState.close() } drawerScope.launch { drawerState.close() }
}, },
onToggleAutomation = { ch ->
controller.setChannelAutomation(ch.chatId, !ch.automation)
drawerScope.launch { drawerState.close() }
},
onDelete = { ch -> onDelete = { ch ->
channelToDelete = ch channelToDelete = ch
drawerScope.launch { drawerState.close() } drawerScope.launch { drawerState.close() }
@@ -973,14 +1014,30 @@ private fun SystemMessage(msg: MessageItem) {
} }
} }
/** M7: header title pill — channel avatar + channel name + "Bot" subtitle. */ /** M7: header title pill — channel avatar + channel name. Automation
* channels show their chat_id as a subtitle (tap the pill to copy it) so
* the user can target them for cron delivery (`deliver="android:<chat_id>"`)
* without asking the agent which channel is in use. */
@Composable @Composable
private fun TitlePill(channel: ChannelInfo?, modifier: Modifier = Modifier) { private fun TitlePill(
channel: ChannelInfo?,
modifier: Modifier = Modifier,
onCopyChatId: (String) -> Unit = {},
) {
val channelName = channel?.name ?: "Iris" val channelName = channel?.name ?: "Iris"
val chatId = channel?.chatId
val isAutomation = channel?.automation == true
Box( Box(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(IrisColors.surface) .background(IrisColors.surface)
.then(
if (isAutomation) {
Modifier.clickable { chatId?.let(onCopyChatId) }
} else {
Modifier
}
)
.padding(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), .padding(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -995,14 +1052,18 @@ private fun TitlePill(channel: ChannelInfo?, modifier: Modifier = Modifier) {
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
if (isAutomation && chatId != null) {
Text( Text(
"Bot", chatId,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = IrisColors.textDim, color = IrisColors.textDim,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
} }
} }
}
} }
/** M7: compact 36dp header icon button (smaller than the 48dp default). */ /** M7: compact 36dp header icon button (smaller than the 48dp default). */
@@ -1087,7 +1148,8 @@ private fun IconImage(base64: String, size: Dp) {
/** /**
* Trailing badges for a channel row: a star marks the default (home) channel, * Trailing badges for a channel row: a star marks the default (home) channel,
* a heart marks a favorited channel. A channel that is both shows both. * a heart marks a favorited channel, a gear marks an automation channel
* (read-only; cron/webhook output only).
*/ */
@Composable @Composable
private fun ChannelBadges(ch: ChannelInfo) { private fun ChannelBadges(ch: ChannelInfo) {
@@ -1109,12 +1171,22 @@ private fun ChannelBadges(ch: ChannelInfo) {
tint = IrisColors.textMuted, tint = IrisColors.textMuted,
) )
} }
if (ch.automation) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Automation channel (read-only)",
modifier = Modifier.size(14.dp),
tint = IrisColors.textMuted,
)
}
} }
/** /**
* Long-press / right-click context menu for a channel row: rename, favorite * Long-press / right-click context menu for a channel row: rename, favorite
* (state-aware), icon (state-aware), delete. The default channel cannot be * (state-aware), icon (state-aware), automation (state-aware), delete. The
* deleted (the server enforces this too), so its "Delete" item is hidden. * default channel cannot be deleted or marked automation (the server enforces
* both), so those items are hidden for it.
*/ */
@Composable @Composable
private fun ChannelContextMenu( private fun ChannelContextMenu(
@@ -1124,6 +1196,7 @@ private fun ChannelContextMenu(
onRename: () -> Unit, onRename: () -> Unit,
onToggleFavorite: () -> Unit, onToggleFavorite: () -> Unit,
onEditIcon: () -> Unit, onEditIcon: () -> Unit,
onToggleAutomation: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
val hasIcon = ch.icon != null || ch.color != null val hasIcon = ch.icon != null || ch.color != null
@@ -1138,6 +1211,10 @@ private fun ChannelContextMenu(
onClick = { onDismiss(); onEditIcon() }, onClick = { onDismiss(); onEditIcon() },
) )
if (!ch.isDefault) { if (!ch.isDefault) {
DropdownMenuItem(
text = { Text(if (ch.automation) "Unmark automation" else "Mark as automation") },
onClick = { onDismiss(); onToggleAutomation() },
)
DropdownMenuItem(text = { Text("Delete") }, onClick = { onDismiss(); onDelete() }) DropdownMenuItem(text = { Text("Delete") }, onClick = { onDismiss(); onDelete() })
} }
} }
@@ -1236,6 +1313,7 @@ private fun ChannelDrawer(
onRename: (ChannelInfo) -> Unit, onRename: (ChannelInfo) -> Unit,
onToggleFavorite: (ChannelInfo) -> Unit, onToggleFavorite: (ChannelInfo) -> Unit,
onEditIcon: (ChannelInfo) -> Unit, onEditIcon: (ChannelInfo) -> Unit,
onToggleAutomation: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit, onDelete: (ChannelInfo) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
) { ) {
@@ -1280,6 +1358,7 @@ private fun ChannelDrawer(
onRename = { onRename(ch) }, onRename = { onRename(ch) },
onToggleFavorite = { onToggleFavorite(ch) }, onToggleFavorite = { onToggleFavorite(ch) },
onEditIcon = { onEditIcon(ch) }, onEditIcon = { onEditIcon(ch) },
onToggleAutomation = { onToggleAutomation(ch) },
onDelete = { onDelete(ch) }, onDelete = { onDelete(ch) },
) )
} }
@@ -1313,6 +1392,7 @@ private fun ChannelRail(
onRename: (ChannelInfo) -> Unit, onRename: (ChannelInfo) -> Unit,
onToggleFavorite: (ChannelInfo) -> Unit, onToggleFavorite: (ChannelInfo) -> Unit,
onEditIcon: (ChannelInfo) -> Unit, onEditIcon: (ChannelInfo) -> Unit,
onToggleAutomation: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit, onDelete: (ChannelInfo) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
) { ) {
@@ -1401,6 +1481,7 @@ private fun ChannelRail(
onRename = { onRename(ch) }, onRename = { onRename(ch) },
onToggleFavorite = { onToggleFavorite(ch) }, onToggleFavorite = { onToggleFavorite(ch) },
onEditIcon = { onEditIcon(ch) }, onEditIcon = { onEditIcon(ch) },
onToggleAutomation = { onToggleAutomation(ch) },
onDelete = { onDelete(ch) }, onDelete = { onDelete(ch) },
) )
} }
@@ -1448,6 +1529,7 @@ private fun InspectorPane(
InfoRow("Name", currentChannel?.name ?: "—") InfoRow("Name", currentChannel?.name ?: "—")
InfoRow("ID", currentChannel?.chatId ?: "—") InfoRow("ID", currentChannel?.chatId ?: "—")
InfoRow("Default", if (currentChannel?.isDefault == true) "yes" else "no") InfoRow("Default", if (currentChannel?.isDefault == true) "yes" else "no")
InfoRow("Automation", if (currentChannel?.automation == true) "yes" else "no")
InfoRow("Threads", threads.size.toString()) InfoRow("Threads", threads.size.toString())
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp)) Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
+13
View File
@@ -44,6 +44,10 @@ gateway identity concepts**.
target a specific thread. target a specific thread.
- The gateway's `create_handoff_thread` is used where hermes wants to open a - The gateway's `create_handoff_thread` is used where hermes wants to open a
named thread (e.g. continuable cron). named thread (e.g. continuable cron).
- **Threading is only active on the default channel.** The topic switcher,
the "new topic" affordance (Ctrl+T), and auto-threading all apply to the
default channel only; user channels stay flat (the gateway also ignores
`auto_thread` for non-default channels).
### 6.3.1 Auto-threading (Telegram topic-mode workflow) ### 6.3.1 Auto-threading (Telegram topic-mode workflow)
@@ -83,6 +87,15 @@ lane (nothing to title / session-scoped, not conversation starters).
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the - **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
directory (rename broadcasts `channel.renamed`; delete is soft — marks directory (rename broadcasts `channel.renamed`; delete is soft — marks
archived, keeps history for search). archived, keeps history for search).
- **Automation channels:** a channel can be marked *automation*
(`channel.set_automation {on}`, long-press / right-click menu; the default
channel cannot be marked). Automation channels are **read-only for the
user**: they exist only to receive gateway-originated output (cron job
output, webhook output). The app replaces the composer with a read-only
notice, and the gateway rejects `message.send` into them
(`error unsupported`). The flag is server-authoritative (synced to all
devices via the `channel.renamed` full-entry response) and shown as a gear
badge in the channel list.
- **App affordance for threads:** long-press (touch) / right-click (desktop) a - **App affordance for threads:** long-press (touch) / right-click (desktop) a
topic chip → "Rename" (`channel.rename`) or "Delete" (`channel.delete`). topic chip → "Rename" (`channel.rename`) or "Delete" (`channel.delete`).
Deleting the open thread falls the app back to the channel's flat lane. Deleting the open thread falls the app back to the channel's flat lane.
+6 -5
View File
@@ -51,7 +51,7 @@
"notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } }, "notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } },
"channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } }, "channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } },
"channel.created": { "description": "May carry auto:true for a thread the gateway minted itself for an incoming message (auto-threading); the name is a derived title, upgraded by a follow-up channel.renamed.", "payload": { "$ref": "#/definitions/channel" } }, "channel.created": { "description": "May carry auto:true for a thread the gateway minted itself for an incoming message (auto-threading); the name is a derived title, upgraded by a follow-up channel.renamed.", "payload": { "$ref": "#/definitions/channel" } },
"channel.renamed": { "description": "Also the response to channel.set_default / channel.favorite / channel.icon (carries the full entry incl. is_default, favorite, icon, color).", "payload": { "$ref": "#/definitions/channel" } }, "channel.renamed": { "description": "Also the response to channel.set_default / channel.favorite / channel.icon / channel.set_automation (carries the full entry incl. is_default, favorite, icon, color, automation).", "payload": { "$ref": "#/definitions/channel" } },
"channel.deleted": { "payload": { "chat_id": { "type": "string" } } }, "channel.deleted": { "payload": { "chat_id": { "type": "string" } } },
"search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } }, "search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } },
"media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } }, "media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } },
@@ -60,13 +60,13 @@
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } }, "error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
"pong": { "payload": { "ts": { "type": "integer" } } }, "pong": { "payload": { "ts": { "type": "integer" } } },
"sync.done": { "payload": { "cursor": { "type": "integer" } } }, "sync.done": { "payload": { "cursor": { "type": "integer" } } },
"history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } } }, "history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } },
"media.pull.end": { "payload": { "ok": { "type": "boolean" } } }, "media.pull.end": { "payload": { "ok": { "type": "boolean" } } },
"media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } } "media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } }
}, },
"app_to_server": { "app_to_server": {
"hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } }, "hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
"message.send": { "payload": { "text": { "type": "string" }, "reply_to": { "type": "string" }, "media_refs": { "type": "array", "items": { "type": "string" } }, "auto_thread": { "type": "boolean", "description": "Optional, default false. Ask the gateway to mint a fresh thread for this message (auto-threading). Honored only in a channel's flat lane (thread_id null) with non-empty non-slash text; the gateway broadcasts channel.created {auto:true} and the echo/turn carry the new thread_id." } } }, "message.send": { "payload": { "text": { "type": "string" }, "reply_to": { "type": "string" }, "media_refs": { "type": "array", "items": { "type": "string" } }, "auto_thread": { "type": "boolean", "description": "Optional, default false. Ask the gateway to mint a fresh thread for this message (auto-threading). Honored only in the DEFAULT channel's flat lane (thread_id null) with non-empty non-slash text — threading is not active on other channels; the gateway broadcasts channel.created {auto:true} and the echo/turn carry the new thread_id." } } },
"media.upload.start": { "payload": { "media_ref": { "type": "string" }, "kind": { "$ref": "#/definitions/kind" }, "mime": { "type": "string" }, "size": { "type": "integer" }, "filename": { "type": "string" } } }, "media.upload.start": { "payload": { "media_ref": { "type": "string" }, "kind": { "$ref": "#/definitions/kind" }, "mime": { "type": "string" }, "size": { "type": "integer" }, "filename": { "type": "string" } } },
"media.upload.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "type": "string" } } }, "media.upload.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "type": "string" } } },
"media.pull": { "payload": { "media_id": { "type": "string" } } }, "media.pull": { "payload": { "media_id": { "type": "string" } } },
@@ -74,7 +74,8 @@
"channel.rename": { "payload": { "name": { "type": "string" } } }, "channel.rename": { "payload": { "name": { "type": "string" } } },
"channel.set_default": { "payload": {} }, "channel.set_default": { "payload": {} },
"channel.favorite": { "description": "Toggle the cosmetic favorite flag (sorts the channel to the top of the list). Answered by a channel.renamed carrying the full entry.", "payload": { "on": { "type": "boolean" } } }, "channel.favorite": { "description": "Toggle the cosmetic favorite flag (sorts the channel to the top of the list). Answered by a channel.renamed carrying the full entry.", "payload": { "on": { "type": "boolean" } } },
"channel.icon": { "description": "Set the channel's cosmetic icon (base64 image) and/or avatar color. Omit / null a field to clear it. Answered by a channel.renamed carrying the full entry.", "payload": { "icon": { "type": ["string", "null"], "description": "base64-encoded image (PNG/JPEG), max ~512 KiB." }, "color": { "type": ["string", "null"], "description": "#RRGGBB avatar color override." } } }, "channel.icon": { "description": "Set the channel's cosmetic icon (base64 image) and/or avatar color. Omit / null a field to clear it. Answered by a channel.renamed carrying the full entry.", "payload": { "icon": { "type": ["string", "null"], "description": "base64-encoded image (PNG/JPEG), max ~512 KiB." }, "color": { "type": ["string", "null"], "description": "#RRGGBB avatar color override." } } },
"channel.set_automation": { "description": "Mark a channel as automation (read-only for the user; it only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. The default channel cannot be marked (error not_found). Answered by a channel.renamed carrying the full entry.", "payload": { "on": { "type": "boolean" } } },
"channel.delete": { "payload": {} }, "channel.delete": { "payload": {} },
"channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} }, "channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} },
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } }, "search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
@@ -87,7 +88,7 @@
}, },
"definitions": { "definitions": {
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] }, "kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."} } }, "channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."}, "automation": {"type":"boolean","description":"Optional; true when the channel is an automation channel (read-only for the user; only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. Never set on the default channel."} } },
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } } "media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } }
}, },
"x-planned-frames": [ "x-planned-frames": [
+50 -1
View File
@@ -1702,6 +1702,23 @@ class AndroidAdapter(BasePlatformAdapter):
chat_id = self.home_channel chat_id = self.home_channel
chat_id = chat_id.strip() chat_id = chat_id.strip()
# Automation channels are read-only for the user: they only receive
# gateway-originated output (cron jobs, webhooks). Reject direct sends
# (the app hides the composer for them, this is the server-side
# enforcement).
target = self._channels.get(chat_id)
if target is not None and target.get("automation"):
await self._ws_server.send_to(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED,
f"{target.get('name') or chat_id} is an automation channel "
"(read-only: cron/webhook output only)",
id=frame.id,
),
)
return
thread_id = frame.thread_id or payload.get("thread_id") thread_id = frame.thread_id or payload.get("thread_id")
if not isinstance(thread_id, str) or not thread_id.strip(): if not isinstance(thread_id, str) or not thread_id.strip():
thread_id = None thread_id = None
@@ -1717,14 +1734,18 @@ class AndroidAdapter(BasePlatformAdapter):
# LLM upgrades the name in the background. The user echo, the agent # LLM upgrades the name in the background. The user echo, the agent
# turn, and all streaming frames then carry the new thread_id. # turn, and all streaming frames then carry the new thread_id.
# Skipped for slash commands (session-scoped, not conversation # Skipped for slash commands (session-scoped, not conversation
# starters) and replies (they continue where the user is). # starters) and replies (they continue where the user is). Threading
# is only active on the default channel; other channels stay flat.
auto_thread = payload.get("auto_thread") is True auto_thread = payload.get("auto_thread") is True
default_entry = self._channels.default()
if ( if (
auto_thread auto_thread
and thread_id is None and thread_id is None
and text.strip() and text.strip()
and not text.lstrip().startswith("/") and not text.lstrip().startswith("/")
and reply_to is None and reply_to is None
and default_entry is not None
and chat_id == default_entry["chat_id"]
): ):
entry = self._channels.create( entry = self._channels.create(
name=_derive_thread_name(text), name=_derive_thread_name(text),
@@ -2160,6 +2181,34 @@ class AndroidAdapter(BasePlatformAdapter):
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._ws_server.broadcast(resp)
async def on_channel_set_automation(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.set_automation requires chat_id", id=frame.id
),
)
return
on = bool(frame.payload.get("on"))
entry = self._channels.set_automation(chat_id, on)
if entry is None:
await self._ws_server.send_to(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND,
f"cannot set automation on {chat_id} (unknown or default)",
id=frame.id,
),
)
return
# Reuse the renamed event shape: it carries the full entry (incl. the
# new automation flag) so every device reconciles the change.
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._ws_server.broadcast(resp)
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None: async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id") chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip(): if not isinstance(chat_id, str) or not chat_id.strip():
+43 -5
View File
@@ -67,7 +67,8 @@ class ChannelDirectory:
created REAL NOT NULL DEFAULT 0, created REAL NOT NULL DEFAULT 0,
favorite INTEGER NOT NULL DEFAULT 0, favorite INTEGER NOT NULL DEFAULT 0,
icon TEXT, icon TEXT,
color TEXT color TEXT,
automation INTEGER NOT NULL DEFAULT 0
) )
""" """
) )
@@ -83,6 +84,10 @@ class ChannelDirectory:
self._conn.execute("ALTER TABLE channels ADD COLUMN icon TEXT") self._conn.execute("ALTER TABLE channels ADD COLUMN icon TEXT")
if "color" not in existing: if "color" not in existing:
self._conn.execute("ALTER TABLE channels ADD COLUMN color TEXT") self._conn.execute("ALTER TABLE channels ADD COLUMN color TEXT")
if "automation" not in existing:
self._conn.execute(
"ALTER TABLE channels ADD COLUMN automation INTEGER NOT NULL DEFAULT 0"
)
self._conn.execute( self._conn.execute(
""" """
CREATE TABLE IF NOT EXISTS counters ( CREATE TABLE IF NOT EXISTS counters (
@@ -138,9 +143,12 @@ class ChannelDirectory:
else: else:
# Refresh the name only if it was never renamed by the user # Refresh the name only if it was never renamed by the user
# (heuristic: still equals the previous default name is not # (heuristic: still equals the previous default name is not
# trackable, so leave user-renamed names alone). # trackable, so leave user-renamed names alone). The default
# channel is the user's chat surface, so it is never an
# automation channel.
self._conn.execute( self._conn.execute(
"UPDATE channels SET kind = ?, is_default = 1 WHERE chat_id = ?", "UPDATE channels SET kind = ?, is_default = 1, automation = 0 "
"WHERE chat_id = ?",
(KIND_DEFAULT, chat_id), (KIND_DEFAULT, chat_id),
) )
# Exactly one default: clear any other default flag. # Exactly one default: clear any other default flag.
@@ -206,7 +214,11 @@ class ChannelDirectory:
return self.get(chat_id) return self.get(chat_id)
def set_default(self, chat_id: str) -> Optional[Dict[str, Any]]: def set_default(self, chat_id: str) -> Optional[Dict[str, Any]]:
"""Mark *chat_id* as the default channel (clears the previous one).""" """Mark *chat_id* as the default channel (clears the previous one).
The default channel is the user's chat surface, so the automation
flag is cleared on it (a read-only home channel would be unusable).
"""
with self._lock: with self._lock:
row = self._conn.execute( row = self._conn.execute(
"SELECT 1 FROM channels WHERE chat_id = ? AND archived = 0", "SELECT 1 FROM channels WHERE chat_id = ? AND archived = 0",
@@ -216,7 +228,9 @@ class ChannelDirectory:
return None return None
self._conn.execute("UPDATE channels SET is_default = 0") self._conn.execute("UPDATE channels SET is_default = 0")
self._conn.execute( self._conn.execute(
"UPDATE channels SET is_default = 1 WHERE chat_id = ?", (chat_id,) "UPDATE channels SET is_default = 1, automation = 0 "
"WHERE chat_id = ?",
(chat_id,),
) )
self._conn.commit() self._conn.commit()
return self.get(chat_id) return self.get(chat_id)
@@ -250,6 +264,29 @@ class ChannelDirectory:
return None return None
return self.get(chat_id) return self.get(chat_id)
def set_automation(self, chat_id: str, on: bool) -> Optional[Dict[str, Any]]:
"""Mark *chat_id* as an automation channel (or clear the flag).
Automation channels are read-only for the user: they only receive
gateway-originated output (cron jobs, webhooks). The app hides the
composer and the gateway rejects ``message.send`` into them. The
default channel cannot be marked automation (it is the user's chat
surface), so this returns ``None`` for it, like ``delete``.
"""
with self._lock:
row = self._conn.execute(
"SELECT is_default FROM channels WHERE chat_id = ? AND archived = 0",
(chat_id,),
).fetchone()
if row is None or row["is_default"]:
return None
self._conn.execute(
"UPDATE channels SET automation = ? WHERE chat_id = ?",
(1 if on else 0, chat_id),
)
self._conn.commit()
return self.get(chat_id)
def delete(self, chat_id: str) -> Optional[Dict[str, Any]]: def delete(self, chat_id: str) -> Optional[Dict[str, Any]]:
"""Soft-delete (archive) a channel. History stays for search. """Soft-delete (archive) a channel. History stays for search.
@@ -365,6 +402,7 @@ def _row_to_entry(row: sqlite3.Row) -> Dict[str, Any]:
"favorite": bool(row["favorite"]), "favorite": bool(row["favorite"]),
"icon": row["icon"], "icon": row["icon"],
"color": row["color"], "color": row["color"],
"automation": bool(row["automation"]),
} }
+3
View File
@@ -60,6 +60,7 @@ TYPE_CHANNEL_RENAME = "channel.rename"
TYPE_CHANNEL_SET_DEFAULT = "channel.set_default" TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
TYPE_CHANNEL_FAVORITE = "channel.favorite" TYPE_CHANNEL_FAVORITE = "channel.favorite"
TYPE_CHANNEL_ICON = "channel.icon" TYPE_CHANNEL_ICON = "channel.icon"
TYPE_CHANNEL_SET_AUTOMATION = "channel.set_automation"
TYPE_CHANNEL_DELETE = "channel.delete" TYPE_CHANNEL_DELETE = "channel.delete"
TYPE_CHANNEL_CREATED = "channel.created" TYPE_CHANNEL_CREATED = "channel.created"
TYPE_CHANNEL_RENAMED = "channel.renamed" TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -442,6 +443,8 @@ def _channel_payload(entry: Dict[str, Any]) -> Dict[str, Any]:
payload["icon"] = entry["icon"] payload["icon"] = entry["icon"]
if entry.get("color"): if entry.get("color"):
payload["color"] = entry["color"] payload["color"] = entry["color"]
if entry.get("automation"):
payload["automation"] = True
return payload return payload
+2
View File
@@ -374,6 +374,8 @@ class WsServer:
await self._adapter.on_channel_favorite(frame, device_id) await self._adapter.on_channel_favorite(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_ICON: elif frame.type == protocol.TYPE_CHANNEL_ICON:
await self._adapter.on_channel_icon(frame, device_id) await self._adapter.on_channel_icon(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_AUTOMATION:
await self._adapter.on_channel_set_automation(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_DELETE: elif frame.type == protocol.TYPE_CHANNEL_DELETE:
await self._adapter.on_channel_delete(frame, device_id) await self._adapter.on_channel_delete(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_LIST: elif frame.type == protocol.TYPE_CHANNEL_LIST: