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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Android also accepts shared text, images and application files. Web content is s
| --- | --- | --- | --- | --- | --- |
| ChatGPT Web | Full | Provider WebView | Yes | Generating + unread | Persistent session; mobile/desktop mode; Codex Cloud shortcut |
| Claude Web | Full | Provider WebView | Yes | Generating + unread | Persistent session |
| Claude Code (official app) | External handoff | Claude-owned app/site | Via Claude | Not tracked by Aistee | Studio Tools → Agents; optional plan/code task, GitHub repo + branch, explicit confirmation, no token proxy |
| Gemini Chat Web | Partial | Fresh Google OAuth may reject embedded user-agents | Yes | Generating + unread | Existing sessions work; embedded OAuth is provider-limited |
| DeepSeek Web | Full | Provider WebView | Yes | Generating + unread | Persistent session |
| Kimi Web | Full | Provider WebView | Yes | Generating + unread | Persistent session |
Expand Down
195 changes: 195 additions & 0 deletions app/src/main/java/ais/tee/ui/screens/ClaudeCodeHandoffPanel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package ais.tee.ui.screens

import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import java.net.URLEncoder

/** Only official Claude Code links. Aistee does not access Claude authentication or run a proxy. */
internal object ClaudeCodeHandoff {
const val SESSIONS_URL = "claude://code"
const val WEB_URL = "https://claude.ai/code"
private const val NEW_SESSION_URL = "https://claude.ai/code/new"
const val MAX_TASK_CHARS = 2_000
private val ownerPattern = Regex("^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$")
private val repoPattern = Regex("^[A-Za-z0-9._-]{1,100}$")

private fun validRepository(value: String): Boolean {
val parts = value.split("/")
return parts.size == 2 && ownerPattern.matches(parts[0]) &&
repoPattern.matches(parts[1]) && parts[1] !in setOf(".", "..")
}

private fun validBranch(value: String): Boolean {
if (value.isEmpty() || value.length > 150 || value == "@" ||
value.startsWith("-") || value.startsWith("/") || value.endsWith("/")
) return false
if (value.contains("..") || value.contains("@{") || value.contains("//")) return false
if (value.any { it.isWhitespace() || it.code < 32 || it.code == 127 ||
it.code == 92 || it in "~^:?*["
}
) return false
return value.split("/").all { part ->
part.isNotEmpty() && !part.startsWith(".") && !part.endsWith(".") &&
!part.endsWith(".lock")
}
}

enum class Mode(val value: String) { PLAN("plan"), CODE("code") }

fun canOpen(task: String, repository: String, branch: String): Boolean {
val repo = repository.trim()
val ref = branch.trim()
return task.isNotBlank() && task.length <= MAX_TASK_CHARS &&
(repo.isEmpty() || validRepository(repo)) &&
(ref.isEmpty() || (repo.isNotEmpty() && validBranch(ref)))
}

fun newSessionUrl(task: String, repository: String = "", branch: String = "", mode: Mode = Mode.PLAN): String {
require(canOpen(task, repository, branch)) { "Invalid Claude Code handoff fields" }
fun encode(value: String): String = URLEncoder.encode(value, "UTF-8").replace("+", "%20")
return buildString {
append(NEW_SESSION_URL)
append("?q=").append(encode(task.trim()))
append("&mode=").append(mode.value)
if (repository.isNotBlank()) {
append("&repo=").append(encode(repository.trim()))
if (branch.isNotBlank()) append("&branch=").append(encode(branch.trim()))
}
}
}
}

/** Optional subscription-account handoff inside the maintained Studio Tools hub. */
@Composable
internal fun ClaudeCodeHandoffPanel(modifier: Modifier = Modifier) {
val context = LocalContext.current
var task by rememberSaveable { mutableStateOf("") }
var repository by rememberSaveable { mutableStateOf("") }
var branch by rememberSaveable { mutableStateOf("") }
var mode by rememberSaveable { mutableStateOf(ClaudeCodeHandoff.Mode.PLAN) }
var confirmation by remember { mutableStateOf(false) }
var notice by remember { mutableStateOf<String?>(null) }

Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)
.testTag("claude_code_handoff"),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Claude Code", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Open a task in the official Claude app or website, using your own Claude account. " +
"No Aistee API key, token transfer or paired computer is needed.",
style = MaterialTheme.typography.bodySmall,
)
OutlinedTextField(
value = task,
onValueChange = { if (it.length <= ClaudeCodeHandoff.MAX_TASK_CHARS) task = it },
label = { Text("Task") },
minLines = 3,
maxLines = 6,
modifier = Modifier.fillMaxWidth().testTag("claude_code_task"),
)
OutlinedTextField(
value = repository,
onValueChange = { repository = it; if (it.isBlank()) branch = "" },
label = { Text("GitHub owner/repository (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("claude_code_repo"),
)
OutlinedTextField(
value = branch,
onValueChange = { branch = it },
label = { Text("Branch (optional)") },
enabled = repository.isNotBlank(),
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("claude_code_branch"),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClaudeCodeHandoff.Mode.entries.forEach { choice ->
FilterChip(
selected = choice == mode,
onClick = { mode = choice },
label = { Text(if (choice == ClaudeCodeHandoff.Mode.PLAN) "Plan" else "Code") },
)
}
}
Text(
"Claude controls access, usage limits and optional extra credits. " +
"No local Aistee files or chats are attached.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { confirmation = true },
enabled = ClaudeCodeHandoff.canOpen(task, repository, branch),
modifier = Modifier.testTag("claude_code_open"),
) { Text("Open in Claude Code") }
TextButton(onClick = {
notice = if (openExternalUri(context, Uri.parse(ClaudeCodeHandoff.SESSIONS_URL))) {
null
} else if (openExternalUri(context, Uri.parse(ClaudeCodeHandoff.WEB_URL))) {
"Claude app unavailable. Opened the Claude Code website instead."
} else {
"No app or browser could open Claude Code."
}
}) { Text("Open Claude Code sessions") }
notice?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}

if (confirmation) {
AlertDialog(
onDismissRequest = { confirmation = false },
title = { Text("Send task to Claude?") },
text = {
Column(
modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Review everything sent to the official Claude Code composer:")
Text("Mode: " + mode.value)
if (repository.isNotBlank()) Text("Repository: " + repository.trim())
if (branch.isNotBlank()) Text("Branch: " + branch.trim())
Text("Task:", fontWeight = FontWeight.Bold)
Text(task, style = MaterialTheme.typography.bodySmall)
}
},
confirmButton = {
TextButton(onClick = {
confirmation = false
val url = ClaudeCodeHandoff.newSessionUrl(task, repository, branch, mode)
notice = if (openExternalUri(context, Uri.parse(url))) null
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
else "No app or browser could open Claude Code."
}) { Text("Open Claude") }
},
dismissButton = {
TextButton(onClick = { confirmation = false }) { Text("Cancel") }
},
)
}
}
7 changes: 7 additions & 0 deletions app/src/main/java/ais/tee/ui/screens/StudioToolsHubScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import ais.tee.ui.viewmodel.StudioViewModel

internal enum class StudioToolsSection {
SKILLS,
BENCHES,
AGENTS,
JOBS,
PRIVACY
}
Expand All @@ -31,6 +33,7 @@ internal enum class StudioToolsSection {
@Composable
fun SkillsBrowserScreen(viewModel: StudioViewModel) {
var section by rememberSaveable { mutableStateOf(StudioToolsSection.SKILLS) }
val stateHolder = rememberSaveableStateHolder()

Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = section.ordinal) {
Expand All @@ -43,6 +46,7 @@ fun SkillsBrowserScreen(viewModel: StudioViewModel) {
when (candidate) {
StudioToolsSection.SKILLS -> "Skills"
StudioToolsSection.BENCHES -> "Benches"
StudioToolsSection.AGENTS -> "Agents"
StudioToolsSection.JOBS -> "Jobs"
StudioToolsSection.PRIVACY -> "Privacy"
}
Expand All @@ -60,6 +64,9 @@ fun SkillsBrowserScreen(viewModel: StudioViewModel) {
StudioToolsSection.BENCHES -> BenchToolsScreen(
modifier = Modifier.weight(1f)
)
StudioToolsSection.AGENTS -> stateHolder.SaveableStateProvider("claude-code") {
ClaudeCodeHandoffPanel(modifier = Modifier.weight(1f))
}
StudioToolsSection.JOBS -> AsyncJobsPanel(
viewModel = viewModel,
modifier = Modifier.weight(1f)
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/ais/tee/ui/screens/WebNavigationSafety.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ internal fun openExternalUri(context: Context, uri: Uri): Boolean {
}
val error = runCatching { context.startActivity(intent) }.exceptionOrNull() ?: return true
when (error) {
is ActivityNotFoundException -> Log.w(WEBVIEW_LOG_TAG, "External URI handler unavailable", error)
is SecurityException -> Log.w(WEBVIEW_LOG_TAG, "External URI launch rejected", error)
is ActivityNotFoundException -> Log.w(WEBVIEW_LOG_TAG, "External URI handler unavailable")
is SecurityException -> Log.w(WEBVIEW_LOG_TAG, "External URI launch rejected")
else -> throw error
}
return false
Expand Down
51 changes: 51 additions & 0 deletions app/src/test/java/ais/tee/ui/screens/ClaudeCodeHandoffTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package ais.tee.ui.screens

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test

class ClaudeCodeHandoffTest {
@Test
fun officialLinkEncodesTaskAndGitHubContextWithoutAnyCredentials() {
val link = ClaudeCodeHandoff.newSessionUrl(
task = "Fix failing CI & add tests",
repository = "travnie/aistee",
branch = "feat/agent-lab",
)
assertEquals(
"https://claude.ai/code/new?q=Fix%20failing%20CI%20%26%20add%20tests" +
"&mode=plan&repo=travnie%2Faistee&branch=feat%2Fagent-lab",
link,
)
assertFalse(link.contains("token"))
assertFalse(link.contains("api_key"))
}

@Test
fun codeModeAndNoRepositoryRemainValid() {
assertEquals(
"https://claude.ai/code/new?q=Review%20a%20patch&mode=code",
ClaudeCodeHandoff.newSessionUrl(" Review a patch ", mode = ClaudeCodeHandoff.Mode.CODE),
)
assertEquals("claude://code", ClaudeCodeHandoff.SESSIONS_URL)
assertEquals("https://claude.ai/code", ClaudeCodeHandoff.WEB_URL)
}

@Test
fun invalidTaskAndReferencesCannotOpen() {
assertFalse(ClaudeCodeHandoff.canOpen(" ", "", ""))
assertFalse(ClaudeCodeHandoff.canOpen("x".repeat(2_001), "", ""))
assertFalse(ClaudeCodeHandoff.canOpen("task", "invalid-repo", ""))
assertFalse(ClaudeCodeHandoff.canOpen("task", "", "main"))
assertFalse(ClaudeCodeHandoff.canOpen("task", "trvny/repo", "bad branch?name"))
assertThrows(IllegalArgumentException::class.java) {
ClaudeCodeHandoff.newSessionUrl("task", "bad/repo/extra")
}
assertTrue(ClaudeCodeHandoff.canOpen("task", "trvny/repo", "feature/task"))
assertTrue(ClaudeCodeHandoff.canOpen("task", "trvny/repo", "release+hotfix@2"))
assertFalse(ClaudeCodeHandoff.canOpen("task", "trvny/repo", "bad..ref"))
assertFalse(ClaudeCodeHandoff.canOpen("task", "trvny/repo", "dir/.hidden"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ class StudioToolsHubScreenTest {
fun skillsRemainTheFirstStudioToolsSection() {
assertEquals(StudioToolsSection.SKILLS, StudioToolsSection.entries.first())
}
@Test
fun agentsAreAnOptionalSectionOfTheExistingToolsHub() {
assertEquals(StudioToolsSection.AGENTS, StudioToolsSection.entries[2])
}
}
8 changes: 8 additions & 0 deletions docs/provider-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,11 @@ Native Android tool calling now uses a separate namespaced tool schema on the `A
**Claude and Google subscription boundaries (verified October 2026):** Anthropic's [Help Center](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan) says subscription allowances may cover Claude Agent SDK / `claude -p` / third-party usage, while its [legal guidance](https://code.claude.com/docs/en/legal-and-compliance) prohibits a third-party app from offering Claude.ai sign-in or brokering Claude consumer tokens. Do not implement direct Claude subscription OAuth or credential transfer in Aistee. An independently authenticated, unmodified Claude Code runtime is a possible optional adapter, subject to Anthropic's current terms. Google [Antigravity FAQ](https://www.antigravity.google/docs/faq/) and [terms](https://antigravity.google/terms) prohibit consumer-plan third-party bridging; use the official app separately or a distinct API-key route. Neither subscription route may silently incur metered API usage.

Protocol reference: https://developers.openai.com/siwc/token-sharing-open-source/codex-app-server

### Claude Code on Android (official account handoff)

Studio Tools → Agents now offers **Claude Code** as an explicit, outbound handoff through the official https://claude.ai/code/new universal link. The user composes a task in Aistee, optionally selects a GitHub owner/repository and branch, and chooses Plan (the default) or Code mode. A confirmation gate shows the text before opening the Claude app or browser. The separate Open Claude Code sessions action uses the official claude://code Android deep link, falling back to the Claude Code website if the app is unavailable (the HTTPS fallback may open the new-session composer). There is no silent sharing of existing native chats, files, API keys or tokens. Claude handles login, model access, session storage, plan allowance and any extra usage on its own site/app.

This is an **independent Android entry point**, not a Claude inference adapter, API proxy, Agent SDK client or remote shell bridge. It works without Legion or any paired computer, subject to Claude Code availability on the user's account. It never bypasses Anthropic's published restrictions on third-party Claude OAuth or consumer credential brokerage.

Official documented URL parameters: https://support.claude.com/en/articles/14898120-open-the-claude-mobile-app-with-a-link . Future optional CLI support may integrate only the unmodified Claude Code binary independently authenticated by its end user, with consent and transport security; it must not replace the ordinary standalone Aistee workflow or silently fall back to metered Claude API billing.
Loading