Compare commits
267
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e2faa7ee | ||
|
|
6ccd79baa0 | ||
|
|
4932cbf3b3 | ||
|
|
1ea463f6cf | ||
|
|
7c27a897ac | ||
|
|
a9717d01c8 | ||
|
|
35a6ff9d0f | ||
|
|
474000935a | ||
|
|
5f8cc2a140 | ||
|
|
432967fe64 | ||
|
|
2512b8be42 | ||
|
|
e3556bd847 | ||
|
|
ce0e4e6820 | ||
|
|
fa3d6c82b2 | ||
|
|
ebe25a1a55 | ||
|
|
923911f631 | ||
|
|
8cbe8510ac | ||
|
|
cd5bbcfcb1 | ||
|
|
cbbf526fc4 | ||
|
|
9f5b71d563 | ||
|
|
de603ccd20 | ||
|
|
c6af78e9dd | ||
|
|
7ab503e4f5 | ||
|
|
eaf11a7696 | ||
|
|
4738233580 | ||
|
|
cb25320ea0 | ||
|
|
ff52dd9812 | ||
|
|
f3a5cad789 | ||
|
|
fffd2a55f3 | ||
|
|
563a35d2ec | ||
|
|
9dead5652e | ||
|
|
cd2184b128 | ||
|
|
1b2e5f5f05 | ||
|
|
2c8956e634 | ||
|
|
f2c0882c36 | ||
|
|
c170cd9d60 | ||
|
|
ebf943bf30 | ||
|
|
645aa2dea0 | ||
|
|
3a2513c78d | ||
|
|
7cb52927f2 | ||
|
|
2724447a51 | ||
|
|
142e9a4870 | ||
|
|
e5c17606d0 | ||
|
|
979bfe2fbe | ||
|
|
8f96027878 | ||
|
|
b6f60bfbae | ||
|
|
a8a335eadb | ||
|
|
048e273ff9 | ||
|
|
beef2049ed | ||
|
|
c30592e6b6 | ||
|
|
abae81a0ce | ||
|
|
66a568818a | ||
|
|
f3d6146884 | ||
|
|
35ae01db93 | ||
|
|
8a05ef2eb7 | ||
|
|
ad71147bd9 | ||
|
|
e9c3882cea | ||
|
|
c9562ab5eb | ||
|
|
bc87ddee06 | ||
|
|
554c9ebb31 | ||
|
|
af5ad1a227 | ||
|
|
8304ef9a49 | ||
|
|
cdba6d07e3 | ||
|
|
ea8c42bfb2 | ||
|
|
e0edbc09d2 | ||
|
|
e9e98e0c49 | ||
|
|
02f1844650 | ||
|
|
1b7ec1f54c | ||
|
|
ee61cea64f | ||
|
|
7e21371e47 | ||
|
|
52ae636dbb | ||
|
|
9d93e6e60e | ||
|
|
babdc678e0 | ||
|
|
9ba1078386 | ||
|
|
f0a1774e4d | ||
|
|
ac720eb266 | ||
|
|
0ba83c3d8f | ||
|
|
e8e98baf4d | ||
|
|
b286323c9c | ||
|
|
71ec7e073c | ||
|
|
46bae13212 | ||
|
|
20d303278d | ||
|
|
589e0144e2 | ||
|
|
0061378193 | ||
|
|
1cf4825b1d | ||
|
|
f799aaf564 | ||
|
|
5af5ceb9a8 | ||
|
|
fbdcec6f69 | ||
|
|
6af4cda650 | ||
|
|
ab4beea2d9 | ||
|
|
44ef5619bf | ||
|
|
6adb5dabf0 | ||
|
|
e9c9e1c647 | ||
|
|
4167b21fe9 | ||
|
|
b9f8f02e11 | ||
|
|
f36b7808a7 | ||
|
|
c2d427691d | ||
|
|
50b862b1fa | ||
|
|
4668cf8ece | ||
|
|
638155de03 | ||
|
|
1c0fff5507 | ||
|
|
10c9db6d87 | ||
|
|
b339fecad3 | ||
|
|
3c10a3a08a | ||
|
|
fd79c8116a | ||
|
|
cb8b93f178 | ||
|
|
60e57fde31 | ||
|
|
8d35f7f8c8 | ||
|
|
bc0fd6ee60 | ||
|
|
ed5a088299 | ||
|
|
9c77965c51 | ||
|
|
49df4c2e9e | ||
|
|
65d7186e1c | ||
|
|
47654aa434 | ||
|
|
6ca2ab1707 | ||
|
|
b0b0160ccb | ||
|
|
c4cd8a691e | ||
|
|
0381a571f2 | ||
|
|
8f3d47c49e | ||
|
|
ba0c54759b | ||
|
|
78912e7925 | ||
|
|
fef9be6c01 | ||
|
|
5387b29049 | ||
|
|
88afd0df3f | ||
|
|
df51aa7be9 | ||
|
|
ac8a1b319d | ||
|
|
17a57269f7 | ||
|
|
43bfda5a60 | ||
|
|
6ffbca3596 | ||
|
|
2cde58b42f | ||
|
|
721c34aa11 | ||
|
|
a7d8b5e22f | ||
|
|
2f695e1354 | ||
|
|
1e30b9ade1 | ||
|
|
b4d4b0712c | ||
|
|
4df8fdb7dc | ||
|
|
b949cffb9e | ||
|
|
25653abcf5 | ||
|
|
f78aa13b36 | ||
|
|
0dc10ffe76 | ||
|
|
0543f66469 | ||
|
|
4a2f7e98b4 | ||
|
|
8c151a5fb2 | ||
|
|
8d0886f9b7 | ||
|
|
9f82400f0a | ||
|
|
059316cbac | ||
|
|
0b1c9e15cf | ||
|
|
e30084ea24 | ||
|
|
bb898a75f3 | ||
|
|
c8e90af91c | ||
|
|
d0df60ac87 | ||
|
|
25244803a9 | ||
|
|
92b41a8742 | ||
|
|
508262bbac | ||
|
|
002f1b466b | ||
|
|
bc27bffbae | ||
|
|
bbf622ed9b | ||
|
|
0c46fb993a | ||
|
|
be554d0ede | ||
|
|
4fda393378 | ||
|
|
a1f3690aaf | ||
|
|
4ce432c227 | ||
|
|
ba7fd68004 | ||
|
|
c3d008d07f | ||
|
|
c355f67133 | ||
|
|
b5c9970e28 | ||
|
|
22d740a36b | ||
|
|
ccdfec8257 | ||
|
|
30f91176ae | ||
|
|
a771d29210 | ||
|
|
913421e244 | ||
|
|
2e7c5f7f58 | ||
|
|
dba3d8c249 | ||
|
|
4c8217385a | ||
|
|
758bacf3fc | ||
|
|
0af91296df | ||
|
|
370edfa763 | ||
|
|
e7086512e7 | ||
|
|
aca8d39886 | ||
|
|
6ae7c3eb97 | ||
|
|
a73eb6284f | ||
|
|
57efb615f6 | ||
|
|
fe26dab6a8 | ||
|
|
8eecd9961e | ||
|
|
ac2eb01bda | ||
|
|
1576380080 | ||
|
|
85feb433ae | ||
|
|
a11933b7bf | ||
|
|
fa40eb7378 | ||
|
|
b680266990 | ||
|
|
ded1f050b2 | ||
|
|
a58ebfed93 | ||
|
|
31b429a840 | ||
|
|
12bd68a2af | ||
|
|
f765d415d6 | ||
|
|
d3e3010245 | ||
|
|
7a0dbbf7f2 | ||
|
|
84fa4a3076 | ||
|
|
eafeac5c05 | ||
|
|
f96f94db8f | ||
|
|
efb54750ed | ||
|
|
9e59121eec | ||
|
|
4a5c3b9982 | ||
|
|
443800bd79 | ||
|
|
d55d92d58a | ||
|
|
41770a54a7 | ||
|
|
6cf0b200f8 | ||
|
|
25344abe4f | ||
|
|
b52a9085e9 | ||
|
|
8bd943a8ca | ||
|
|
b86335ddf1 | ||
|
|
c348381c5f | ||
|
|
dabdf9fcba | ||
|
|
b76dc06a09 | ||
|
|
23fbfdc5b8 | ||
|
|
ff34727e3c | ||
|
|
e9ede49256 | ||
|
|
8c97ef0616 | ||
|
|
9533672e09 | ||
|
|
b11784e8e3 | ||
|
|
42ffc1a352 | ||
|
|
a50f098c8a | ||
|
|
32d4058c96 | ||
|
|
74a423567f | ||
|
|
b225fbdd69 | ||
|
|
244e8d2927 | ||
|
|
4a1a10c49a | ||
|
|
d607740fea | ||
|
|
0b34d1608f | ||
|
|
075dea509a | ||
|
|
c067f48a24 | ||
|
|
51f96cfb98 | ||
|
|
8f5d96a95b | ||
|
|
3e98b3ab24 | ||
|
|
24ed1ac002 | ||
|
|
3529947497 | ||
|
|
4851ad9101 | ||
|
|
64741a027e | ||
|
|
cdd4213ff5 | ||
|
|
f8973b3802 | ||
|
|
1e2c0f0a38 | ||
|
|
6fa7dc63e4 | ||
|
|
5c0a4c4640 | ||
|
|
8773671afd | ||
|
|
d9815d86c6 | ||
|
|
13009d0f0e | ||
|
|
bbae09bc48 | ||
|
|
c7c294996e | ||
|
|
3c638b08de | ||
|
|
be23a2b539 | ||
|
|
b98f5cb3e5 | ||
|
|
0f5576c77a | ||
|
|
0292f373f7 | ||
|
|
eea337ce36 | ||
|
|
fd3ea64949 | ||
|
|
046c110460 | ||
|
|
6eb65f1057 | ||
|
|
3a68c1dd2f | ||
|
|
fde1954eac | ||
|
|
25435c34b2 | ||
|
|
4a956e9c38 | ||
|
|
2a49814572 | ||
|
|
d1aa56bab3 | ||
|
|
3cb4bebd1e | ||
|
|
0e20baf7c2 | ||
|
|
af8acbfbf2 | ||
|
|
1ad7845f51 |
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ worktrees/
|
|||||||
|
|
||||||
# `nix run .#dev` scratch state (tmux socket)
|
# `nix run .#dev` scratch state (tmux socket)
|
||||||
/.dev/
|
/.dev/
|
||||||
|
|
||||||
|
# Per-machine Claude Code tool permissions
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ clients — how it opens, the sigils, the key model, the match-and-rank algorith
|
|||||||
the two matchers must agree on, and the fixture table they are tested against.
|
the two matchers must agree on, and the fixture table they are tested against.
|
||||||
Read it before changing either bar or either matcher.
|
Read it before changing either bar or either matcher.
|
||||||
|
|
||||||
|
`docs/STRUCTURED-VIEW.md` is the contract for a structured session (an agent
|
||||||
|
run as a daemon child over ACP instead of in a tmux pane) — the eight
|
||||||
|
transcript row kinds, the two projections and their cursor/replay semantics,
|
||||||
|
the handshake and declined capabilities, and the approval nonce rules. Read
|
||||||
|
it before changing anything that renders a structured session.
|
||||||
|
|
||||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||||
|
|||||||
Generated
+3
@@ -1467,6 +1467,7 @@ dependencies = [
|
|||||||
"maestro-cli",
|
"maestro-cli",
|
||||||
"maestro-daemon",
|
"maestro-daemon",
|
||||||
"maestro-tui",
|
"maestro-tui",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@@ -1477,6 +1478,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"libc",
|
"libc",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -1644,6 +1646,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.21.0",
|
"tokio-tungstenite 0.21.0",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"unicode-width",
|
||||||
"uuid",
|
"uuid",
|
||||||
"vt100",
|
"vt100",
|
||||||
]
|
]
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,7 @@ repository = "https://git.naps.pt/naps62/maestro"
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
agent-client-protocol = "2.1"
|
agent-client-protocol = { version = "2.1", features = ["unstable_tool_call_name"] }
|
||||||
axum = { version = "0.7", features = ["ws"] }
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
@@ -44,6 +44,7 @@ tokio-util = { version = "0.7", features = ["compat"] }
|
|||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
unicode-width = "0.1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
ratatui = "0.28"
|
ratatui = "0.28"
|
||||||
vt100 = "0.15"
|
vt100 = "0.15"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
/local.properties
|
||||||
|
/.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build/
|
||||||
|
/captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "pt.naps.maestro"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "pt.naps.maestro"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
getByName("debug") {
|
||||||
|
storeFile = file("debug.keystore")
|
||||||
|
storePassword = "android"
|
||||||
|
keyAlias = "androiddebugkey"
|
||||||
|
keyPassword = "android"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
|
||||||
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
|
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
|
}
|
||||||
Binary file not shown.
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.kts.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package pt.naps.maestro
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MainActivityTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun placeholderScreen_showsAppName() {
|
||||||
|
composeTestRule.onNodeWithText("Maestro").assertExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Maestro">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.Maestro">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package pt.naps.maestro
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.ui.theme.MaestroTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
MaestroTheme {
|
||||||
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
|
PlaceholderScreen(modifier = Modifier.padding(innerPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaceholderScreen(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Maestro",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.placeholder_message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun PlaceholderScreenPreview() {
|
||||||
|
MaestroTheme {
|
||||||
|
PlaceholderScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
|
||||||
|
/** A `/api` failure with the daemon's own error envelope kept intact, so a
|
||||||
|
* caller can branch on [code] and render [unsavedWork] — the kill guard —
|
||||||
|
* rather than re-parsing [detail]. Mirrors the web client's
|
||||||
|
* `ApiRequestError`. */
|
||||||
|
class MaestroApiException(
|
||||||
|
val status: Int,
|
||||||
|
val code: String,
|
||||||
|
val detail: String,
|
||||||
|
val unsavedWork: InTheWay? = null,
|
||||||
|
) : Exception(buildMessage(status, code, detail)) {
|
||||||
|
private companion object {
|
||||||
|
fun buildMessage(status: Int, code: String, detail: String): String {
|
||||||
|
val info = if (code.isNotEmpty()) "$code: $detail" else detail
|
||||||
|
return if (info.isNotEmpty()) "$status — $info" else "$status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import pt.naps.maestro.data.ApiError
|
||||||
|
import pt.naps.maestro.data.BranchInfo
|
||||||
|
import pt.naps.maestro.data.CreateSessionRequest
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
import pt.naps.maestro.data.DirListing
|
||||||
|
import pt.naps.maestro.data.GitRoot
|
||||||
|
import pt.naps.maestro.data.NameBatch
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
import pt.naps.maestro.data.RecentDirs
|
||||||
|
import pt.naps.maestro.data.RenameSessionRequest
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SetFocusRequest
|
||||||
|
import pt.naps.maestro.data.StructuredAttachmentResponse
|
||||||
|
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||||
|
import pt.naps.maestro.data.StructuredConfigRequest
|
||||||
|
import pt.naps.maestro.data.StructuredPromptRequest
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||||
|
private val OCTET_STREAM_MEDIA_TYPE = "application/octet-stream".toMediaType()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed client for the daemon's `/api` surface (REST half). Mirrors
|
||||||
|
* `maestro_protocol::client::DaemonClient` / `web/src/api.ts`.
|
||||||
|
*
|
||||||
|
* There is no authentication layer: reaching the daemon's bind address is
|
||||||
|
* the authorization, same trust model as tmux itself.
|
||||||
|
*/
|
||||||
|
class MaestroClient(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val httpClient: OkHttpClient = OkHttpClient(),
|
||||||
|
) {
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun listSessions(): List<Session> = get(Routes.SESSIONS)
|
||||||
|
|
||||||
|
suspend fun createSession(request: CreateSessionRequest): Session =
|
||||||
|
post(Routes.SESSIONS, request)
|
||||||
|
|
||||||
|
/** `PATCH /api/sessions/:id`: rename a live session. An empty [name]
|
||||||
|
* clears the custom one, so the card falls back to the command. */
|
||||||
|
suspend fun renameSession(id: Uuid, name: String): Session =
|
||||||
|
patch(Routes.session(id), RenameSessionRequest(name))
|
||||||
|
|
||||||
|
/** `DELETE /api/sessions/:id`. With [force], the daemon overrides the
|
||||||
|
* kill refusal over a worktree holding unsaved work and leaves that
|
||||||
|
* worktree on disk. */
|
||||||
|
suspend fun killSession(id: Uuid, force: Boolean = false) {
|
||||||
|
val query = if (force) listOf(Routes.FORCE_QUERY to "true") else emptyList()
|
||||||
|
executeNoContent(newRequest("DELETE", Routes.session(id), query = query))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/focus`: report whether this client's pane is
|
||||||
|
* the one currently showing [id] — not merely highlighted in a
|
||||||
|
* sidebar. */
|
||||||
|
suspend fun setSessionFocus(id: Uuid, focused: Boolean) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.sessionFocus(id),
|
||||||
|
body = json.encodeToString(SetFocusRequest(focused)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/dirs?path=`: one directory's immediate subdirectories.
|
||||||
|
* [path] may be empty, `~`, relative, or absolute — the daemon resolves
|
||||||
|
* it against its home dir. */
|
||||||
|
suspend fun listDir(path: String): DirListing =
|
||||||
|
get(Routes.DIRS, listOf(Routes.PATH_QUERY to path))
|
||||||
|
|
||||||
|
/** `GET /api/info`: identity + spawn presets of the connected daemon. */
|
||||||
|
suspend fun getDaemonInfo(): DaemonInfo = get(Routes.INFO)
|
||||||
|
|
||||||
|
/** `GET /api/recent-dirs`: recently-used working dirs, newest-first and
|
||||||
|
* deduped by dir. */
|
||||||
|
suspend fun recentDirs(): RecentDirs = get(Routes.RECENT_DIRS)
|
||||||
|
|
||||||
|
/** `GET /api/projects`: every discovered git repository/worktree,
|
||||||
|
* newest-activity-first. */
|
||||||
|
suspend fun listProjects(): List<Project> = get(Routes.PROJECTS)
|
||||||
|
|
||||||
|
/** `GET /api/gitroot?path=`: the enclosing git repo of [path]; absent
|
||||||
|
* when [path] isn't inside a repo. */
|
||||||
|
suspend fun gitRoot(path: String): GitRoot =
|
||||||
|
get(Routes.GITROOT, listOf(Routes.PATH_QUERY to path))
|
||||||
|
|
||||||
|
/** `GET /api/names?count=`: a batch of fresh session handles. */
|
||||||
|
suspend fun fetchNames(count: Int): NameBatch =
|
||||||
|
get(Routes.NAMES, listOf(Routes.COUNT_QUERY to count.toString()))
|
||||||
|
|
||||||
|
/** `GET /api/branches?path=`: one repository's local branches, with
|
||||||
|
* worktree occupancy. */
|
||||||
|
suspend fun listBranches(path: String): List<BranchInfo> =
|
||||||
|
get(Routes.BRANCHES, listOf(Routes.PATH_QUERY to path))
|
||||||
|
|
||||||
|
/** Send a prompt to a structured session's ACP turn. `409
|
||||||
|
* turn_in_flight` when one is already active. */
|
||||||
|
suspend fun sendStructuredPrompt(id: Uuid, prompt: String) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.structuredPrompt(id),
|
||||||
|
body = json.encodeToString(StructuredPromptRequest(prompt)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload a file attachment for a structured session's next prompt. */
|
||||||
|
suspend fun uploadStructuredAttachment(
|
||||||
|
id: Uuid,
|
||||||
|
fileName: String,
|
||||||
|
contents: ByteArray,
|
||||||
|
): StructuredAttachmentResponse {
|
||||||
|
val url = Routes.httpUrl(baseUrl, Routes.structuredAttachment(id)).toHttpUrl()
|
||||||
|
.newBuilder()
|
||||||
|
.addQueryParameter("name", fileName)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.post(contents.toRequestBody(OCTET_STREAM_MEDIA_TYPE))
|
||||||
|
.build()
|
||||||
|
return decode(execute(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a structured session's active ACP turn. */
|
||||||
|
suspend fun cancelStructuredTurn(id: Uuid) {
|
||||||
|
executeNoContent(newRequest("POST", Routes.structuredCancel(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change one option advertised by a live ACP session. */
|
||||||
|
suspend fun setStructuredConfig(id: Uuid, configId: String, value: JsonElement) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.structuredConfig(id),
|
||||||
|
body = json.encodeToString(StructuredConfigRequest(configId, value)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer a pending ACP permission request. Always resolves once the
|
||||||
|
* session itself is a live structured session — a nonce that is unknown
|
||||||
|
* or already consumed still answers success. */
|
||||||
|
suspend fun resolveStructuredApproval(
|
||||||
|
id: Uuid,
|
||||||
|
nonce: String,
|
||||||
|
decision: StructuredApprovalDecision,
|
||||||
|
) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.structuredApproval(id, nonce),
|
||||||
|
body = json.encodeToString(decision),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- plumbing ------------------------------------------------------
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> get(
|
||||||
|
path: String,
|
||||||
|
query: List<Pair<String, String>> = emptyList(),
|
||||||
|
): T = decode(execute(newRequest("GET", path, query = query)))
|
||||||
|
|
||||||
|
private suspend inline fun <reified B, reified T> post(path: String, body: B): T =
|
||||||
|
decode(execute(newRequest("POST", path, body = json.encodeToString(body))))
|
||||||
|
|
||||||
|
private suspend inline fun <reified B, reified T> patch(path: String, body: B): T =
|
||||||
|
decode(execute(newRequest("PATCH", path, body = json.encodeToString(body))))
|
||||||
|
|
||||||
|
private fun newRequest(
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
query: List<Pair<String, String>> = emptyList(),
|
||||||
|
body: String? = null,
|
||||||
|
): Request {
|
||||||
|
val urlBuilder = Routes.httpUrl(baseUrl, path).toHttpUrl().newBuilder()
|
||||||
|
for ((key, value) in query) urlBuilder.addQueryParameter(key, value)
|
||||||
|
// OkHttp refuses a null body on POST/PUT/PATCH even when the daemon
|
||||||
|
// expects none (e.g. the structured-turn cancel) — an empty body
|
||||||
|
// satisfies it without implying a JSON payload.
|
||||||
|
val requestBody = when {
|
||||||
|
body != null -> body.toRequestBody(JSON_MEDIA_TYPE)
|
||||||
|
method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody(null)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return Request.Builder()
|
||||||
|
.url(urlBuilder.build())
|
||||||
|
.method(method, requestBody)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun execute(request: Request): Response =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val call = httpClient.newCall(request)
|
||||||
|
cont.invokeOnCancellation { call.cancel() }
|
||||||
|
call.enqueue(
|
||||||
|
object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
if (!cont.isCancelled) cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
cont.resume(response)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun executeNoContent(request: Request) {
|
||||||
|
execute(request).use { if (!it.isSuccessful) throw toApiException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> decode(response: Response): T =
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) throw toApiException(it)
|
||||||
|
json.decodeFromString<T>(it.body?.string().orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toApiException(response: Response): MaestroApiException {
|
||||||
|
val text = response.body?.string().orEmpty()
|
||||||
|
val info = runCatching { json.decodeFromString<ApiError>(text) }.getOrNull()?.error
|
||||||
|
return MaestroApiException(
|
||||||
|
status = response.code,
|
||||||
|
code = info?.code ?: "",
|
||||||
|
detail = info?.message ?: text,
|
||||||
|
unsavedWork = info?.unsaved_work,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.ByteString
|
||||||
|
import pt.naps.maestro.data.CatalogEvent
|
||||||
|
import pt.naps.maestro.data.ClientControl
|
||||||
|
import pt.naps.maestro.data.StructuredEvent
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
private val wsJson = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One frame received on `WS /api/attach/:id`: raw PTY bytes, or a decoded
|
||||||
|
* [ClientControl] text message. */
|
||||||
|
sealed class AttachFrame {
|
||||||
|
data class Pty(val bytes: ByteArray) : AttachFrame()
|
||||||
|
data class Control(val message: ClientControl) : AttachFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live `WS /api/attach/:id` connection. PTY bytes travel in binary frames
|
||||||
|
* both directions; control messages ([ClientControl]) travel in text
|
||||||
|
* frames. */
|
||||||
|
class AttachConnection internal constructor(
|
||||||
|
private val socket: WebSocket,
|
||||||
|
val frames: Flow<AttachFrame>,
|
||||||
|
) {
|
||||||
|
/** Type raw bytes into the session's pane, as if typed on an attached
|
||||||
|
* terminal. */
|
||||||
|
fun sendInput(bytes: ByteArray) {
|
||||||
|
socket.send(ByteString.of(*bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resize(cols: Int, rows: Int) {
|
||||||
|
sendControl(ClientControl.Resize(cols, rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ping() {
|
||||||
|
sendControl(ClientControl.Ping)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendControl(message: ClientControl) {
|
||||||
|
socket.send(wsJson.encodeToString(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
socket.close(1000, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live `WS /api/structured/:id` connection: transcript/state snapshots
|
||||||
|
* and live deltas for one structured session. */
|
||||||
|
class StructuredConnection internal constructor(
|
||||||
|
private val socket: WebSocket,
|
||||||
|
val events: Flow<StructuredEvent>,
|
||||||
|
) {
|
||||||
|
fun close() {
|
||||||
|
socket.close(1000, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live `WS /api/events` connection: catalog snapshot + session deltas. */
|
||||||
|
class EventsConnection internal constructor(
|
||||||
|
private val socket: WebSocket,
|
||||||
|
val events: Flow<CatalogEvent>,
|
||||||
|
) {
|
||||||
|
fun close() {
|
||||||
|
socket.close(1000, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket half of the daemon's `/api` surface. Mirrors the `*_url`
|
||||||
|
* helpers in `crates/maestro-protocol/src/routes.rs`.
|
||||||
|
*
|
||||||
|
* Each `connect*` call opens the socket immediately and returns a handle
|
||||||
|
* whose `Flow` starts buffering frames right away — a slow collector never
|
||||||
|
* misses a frame, it only sees it later. Call `close()` on the returned
|
||||||
|
* connection when done; there is no automatic reconnect.
|
||||||
|
*/
|
||||||
|
class MaestroWebSocket(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val httpClient: OkHttpClient = OkHttpClient(),
|
||||||
|
) {
|
||||||
|
/** `WS /api/events`: catalog snapshot + session deltas. */
|
||||||
|
fun connectEvents(): EventsConnection {
|
||||||
|
val channel = Channel<CatalogEvent>(Channel.BUFFERED)
|
||||||
|
val socket = open(
|
||||||
|
Routes.eventsWsUrl(baseUrl),
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
runCatching { wsJson.decodeFromString<CatalogEvent>(text) }
|
||||||
|
.onSuccess { channel.trySend(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
channel.close(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return EventsConnection(socket, channel.receiveAsFlow())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `WS /api/attach/:id`: raw PTY bytes both directions, control messages
|
||||||
|
* as text JSON. */
|
||||||
|
fun connectAttach(id: Uuid): AttachConnection {
|
||||||
|
val channel = Channel<AttachFrame>(Channel.BUFFERED)
|
||||||
|
val socket = open(
|
||||||
|
Routes.attachWsUrl(baseUrl, id),
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
runCatching { wsJson.decodeFromString<ClientControl>(text) }
|
||||||
|
.onSuccess { channel.trySend(AttachFrame.Control(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||||
|
channel.trySend(AttachFrame.Pty(bytes.toByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
channel.close(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return AttachConnection(socket, channel.receiveAsFlow())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `WS /api/structured/:id`: transcript/state snapshots and live deltas
|
||||||
|
* for one structured session. */
|
||||||
|
fun connectStructured(id: Uuid): StructuredConnection {
|
||||||
|
val channel = Channel<StructuredEvent>(Channel.BUFFERED)
|
||||||
|
val socket = open(
|
||||||
|
Routes.structuredWsUrl(baseUrl, id),
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
runCatching { wsJson.decodeFromString<StructuredEvent>(text) }
|
||||||
|
.onSuccess { channel.trySend(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
channel.close(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return StructuredConnection(socket, channel.receiveAsFlow())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(url: String, listener: WebSocketListener): WebSocket {
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
return httpClient.newWebSocket(request, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/** The daemon's `/api` surface: paths, and the scheme swap needed to reach
|
||||||
|
* the WebSocket endpoints. Mirrors `maestro-protocol::routes` /
|
||||||
|
* `web/src/routes.ts`. */
|
||||||
|
object Routes {
|
||||||
|
const val API_PREFIX = "/api"
|
||||||
|
|
||||||
|
const val SESSIONS = "/api/sessions"
|
||||||
|
const val INFO = "/api/info"
|
||||||
|
const val DIRS = "/api/dirs"
|
||||||
|
const val RECENT_DIRS = "/api/recent-dirs"
|
||||||
|
const val PROJECTS = "/api/projects"
|
||||||
|
const val GITROOT = "/api/gitroot"
|
||||||
|
const val NAMES = "/api/names"
|
||||||
|
const val BRANCHES = "/api/branches"
|
||||||
|
const val EVENTS = "/api/events"
|
||||||
|
|
||||||
|
/** Name of the query parameter [DIRS], [GITROOT] and [BRANCHES] take. */
|
||||||
|
const val PATH_QUERY = "path"
|
||||||
|
|
||||||
|
/** Name of the query parameter `DELETE /api/sessions/:id` takes:
|
||||||
|
* `force=true` overrides the kill refusal over a guarded worktree. */
|
||||||
|
const val FORCE_QUERY = "force"
|
||||||
|
|
||||||
|
/** Name of the query parameter [NAMES] takes. */
|
||||||
|
const val COUNT_QUERY = "count"
|
||||||
|
|
||||||
|
fun session(id: Uuid): String = "$SESSIONS/$id"
|
||||||
|
|
||||||
|
fun sessionFocus(id: Uuid): String = "${session(id)}/focus"
|
||||||
|
|
||||||
|
fun attach(id: Uuid): String = "$API_PREFIX/attach/$id"
|
||||||
|
|
||||||
|
/** `WS /api/structured/:id` — transcript/state snapshots and live
|
||||||
|
* deltas. */
|
||||||
|
fun structured(id: Uuid): String = "$API_PREFIX/structured/$id"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
|
||||||
|
fun structuredPrompt(id: Uuid): String = "${session(id)}/structured/prompt"
|
||||||
|
|
||||||
|
fun structuredAttachment(id: Uuid): String = "${session(id)}/structured/attachments"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
|
||||||
|
fun structuredCancel(id: Uuid): String = "${session(id)}/structured/cancel"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/config` — change an ACP session
|
||||||
|
* option. */
|
||||||
|
fun structuredConfig(id: Uuid): String = "${session(id)}/structured/config"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a
|
||||||
|
* permission request. */
|
||||||
|
fun structuredApproval(id: Uuid, nonce: String): String =
|
||||||
|
"${session(id)}/structured/approvals/$nonce"
|
||||||
|
|
||||||
|
/** Strip a trailing slash so `{base}{path}` never doubles up. */
|
||||||
|
fun normalizeBase(base: String): String = base.trimEnd('/')
|
||||||
|
|
||||||
|
/** Join a daemon base URL and one of this object's paths. */
|
||||||
|
fun httpUrl(base: String, path: String): String = "${normalizeBase(base)}$path"
|
||||||
|
|
||||||
|
/** Swap an HTTP base URL for its WebSocket equivalent: `http://` becomes
|
||||||
|
* `ws://`, `https://` becomes `wss://`; a base already `ws(s)://` passes
|
||||||
|
* through. */
|
||||||
|
fun wsBase(httpBase: String): String {
|
||||||
|
val base = normalizeBase(httpBase)
|
||||||
|
return when {
|
||||||
|
base.startsWith("https://") -> "wss://${base.removePrefix("https://")}"
|
||||||
|
base.startsWith("http://") -> "ws://${base.removePrefix("http://")}"
|
||||||
|
base.startsWith("wss://") || base.startsWith("ws://") -> base
|
||||||
|
else -> throw IllegalArgumentException(
|
||||||
|
"daemon url has no supported scheme (want http/https/ws/wss): $httpBase",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun eventsWsUrl(base: String): String = "${wsBase(base)}$EVENTS"
|
||||||
|
|
||||||
|
fun attachWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${attach(id)}"
|
||||||
|
|
||||||
|
fun structuredWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${structured(id)}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session activity, orthogonal to [SessionStatus]. Mirrors
|
||||||
|
* `maestro_protocol::ActivityState`.
|
||||||
|
*
|
||||||
|
* Deserializes leniently: an unknown variant from a newer daemon decodes as
|
||||||
|
* [Idle] instead of failing the whole [Session].
|
||||||
|
*/
|
||||||
|
@Serializable(with = ActivityStateSerializer::class)
|
||||||
|
enum class ActivityState {
|
||||||
|
Active,
|
||||||
|
Idle,
|
||||||
|
AwaitingInput,
|
||||||
|
Error,
|
||||||
|
Starting,
|
||||||
|
}
|
||||||
|
|
||||||
|
object ActivityStateSerializer : KSerializer<ActivityState> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("ActivityState", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: ActivityState) {
|
||||||
|
encoder.encodeString(value.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): ActivityState =
|
||||||
|
when (decoder.decodeString()) {
|
||||||
|
"Active" -> ActivityState.Active
|
||||||
|
"AwaitingInput" -> ActivityState.AwaitingInput
|
||||||
|
"Error" -> ActivityState.Error
|
||||||
|
"Starting" -> ActivityState.Starting
|
||||||
|
else -> ActivityState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Structured failure body inside an [ApiError] envelope. Mirrors
|
||||||
|
* `maestro_protocol::ErrorInfo`. */
|
||||||
|
@Serializable
|
||||||
|
data class ErrorInfo(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
/** Set only by the kill guard: what blocked an unforced
|
||||||
|
* `DELETE /api/sessions/:id`. */
|
||||||
|
val unsaved_work: InTheWay? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The envelope every `/api` failure is wrapped in: `{"error": {...}}`.
|
||||||
|
* Mirrors `maestro_protocol::ApiError`. */
|
||||||
|
@Serializable
|
||||||
|
data class ApiError(val error: ErrorInfo)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** One local branch of a repository, from `GET /api/branches`. A client
|
||||||
|
* disables the entry when [is_head] is set or [worktree] is present — git
|
||||||
|
* refuses to check the same branch out in two worktrees at once. Mirrors
|
||||||
|
* `maestro_protocol::BranchInfo`. */
|
||||||
|
@Serializable
|
||||||
|
data class BranchInfo(
|
||||||
|
/** Short branch name (`main`, `feature/foo`). */
|
||||||
|
val name: String,
|
||||||
|
/** Whether this branch is the main checkout's current HEAD. */
|
||||||
|
val is_head: Boolean,
|
||||||
|
/** Toplevel path of the linked worktree holding this branch, if any. */
|
||||||
|
val worktree: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages the daemon broadcasts to clients over `WS /api/events`. Mirrors
|
||||||
|
* `maestro_protocol::CatalogEvent`; tagged by a `type` field.
|
||||||
|
*
|
||||||
|
* A subscriber receives a single [Snapshot] immediately on connect, then a
|
||||||
|
* stream of deltas as sessions are created, updated, and killed. Skipped
|
||||||
|
* events (broadcast channel lag) surface as [Lagged].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed class CatalogEvent {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Snapshot")
|
||||||
|
data class Snapshot(val sessions: List<Session>) : CatalogEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("SessionAdded")
|
||||||
|
data class SessionAdded(val session: Session) : CatalogEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("SessionRemoved")
|
||||||
|
data class SessionRemoved(val id: Uuid) : CatalogEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("SessionUpdated")
|
||||||
|
data class SessionUpdated(val session: Session) : CatalogEvent()
|
||||||
|
|
||||||
|
/** Server-issued notification that the broadcast queue dropped frames.
|
||||||
|
* Clients should re-fetch a full snapshot. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Lagged")
|
||||||
|
data class Lagged(val skipped: Long) : CatalogEvent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Text-frame control messages on a `WS /api/attach/:id` connection. PTY
|
||||||
|
* bytes travel in binary frames and are not modeled here. Mirrors
|
||||||
|
* `maestro_protocol::ClientControl`; tagged by a `type` field. */
|
||||||
|
@Serializable
|
||||||
|
sealed class ClientControl {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Resize")
|
||||||
|
data class Resize(val cols: Int, val rows: Int) : ClientControl()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Ping")
|
||||||
|
object Ping : ClientControl()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Pong")
|
||||||
|
object Pong : ClientControl()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Error")
|
||||||
|
data class Error(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
val unsaved_work: InTheWay? = null,
|
||||||
|
) : ClientControl()
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/info` — the daemon's identity plus its spawn presets. Mirrors
|
||||||
|
* `maestro_protocol::DaemonInfo`. */
|
||||||
|
@Serializable
|
||||||
|
data class DaemonInfo(
|
||||||
|
val hostname: String = "",
|
||||||
|
/** The protocol version the daemon implements. */
|
||||||
|
val version: String = "",
|
||||||
|
/** Home dir of the user the daemon runs as; empty for older daemons. */
|
||||||
|
val home_dir: String = "",
|
||||||
|
val presets: List<SpawnPreset> = emptyList(),
|
||||||
|
val build: BuildInfo = BuildInfo(),
|
||||||
|
/** Identity of the daemon *process*, regenerated on every start — a
|
||||||
|
* changed value across a reconnect means the daemon restarted. */
|
||||||
|
val instance: String = "",
|
||||||
|
/** Canonical leader key the operator configured (`[tui].leader`), so a
|
||||||
|
* client binds the same chord the TUI does. Empty means "use your
|
||||||
|
* default". */
|
||||||
|
val leader: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A spawn-form command preset advertised by the daemon. */
|
||||||
|
@Serializable
|
||||||
|
data class SpawnPreset(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val command: String,
|
||||||
|
val auto_approve: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Build provenance of the running daemon. Every field is empty when the
|
||||||
|
* binary was built outside a git checkout. */
|
||||||
|
@Serializable
|
||||||
|
data class BuildInfo(
|
||||||
|
val commit: String = "",
|
||||||
|
val commit_date: String = "",
|
||||||
|
val dirty: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/dirs?path=` — one directory's immediate subdirectories. Mirrors
|
||||||
|
* `maestro_protocol::DirListing`. */
|
||||||
|
@Serializable
|
||||||
|
data class DirListing(
|
||||||
|
/** Resolved absolute directory that was listed (after ~/relative
|
||||||
|
* expansion). */
|
||||||
|
val path: String,
|
||||||
|
/** Immediate subdirectory names, sorted case-insensitively. */
|
||||||
|
val entries: List<String> = emptyList(),
|
||||||
|
/** True when the listing was capped server-side. */
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One deduplicated recent working dir for the spawn form's recents picker.
|
||||||
|
* Mirrors `maestro_protocol::RecentDir`. */
|
||||||
|
@Serializable
|
||||||
|
data class RecentDir(
|
||||||
|
val cwd: String,
|
||||||
|
val last_command: String,
|
||||||
|
val last_used: IsoTimestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /api/recent-dirs` — the daemon's recent working dirs, newest-first
|
||||||
|
* and deduped by `cwd`. */
|
||||||
|
@Serializable
|
||||||
|
data class RecentDirs(
|
||||||
|
val dirs: List<RecentDir> = emptyList(),
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or
|
||||||
|
* `null` when the path isn't inside a repo. Mirrors
|
||||||
|
* `maestro_protocol::GitRoot`. */
|
||||||
|
@Serializable
|
||||||
|
data class GitRoot(val root: String? = null)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
/** A session id. Mirrors `uuid::Uuid` on the wire — a plain UUID string. */
|
||||||
|
typealias Uuid = String
|
||||||
|
|
||||||
|
/** An RFC 3339 timestamp, as the daemon serializes `chrono::DateTime<Utc>`. */
|
||||||
|
typealias IsoTimestamp = String
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** What currently blocks an unforced kill of a session's worktree,
|
||||||
|
* recomputed by the daemon on every read. Mirrors
|
||||||
|
* `maestro_protocol::InTheWay`. */
|
||||||
|
@Serializable
|
||||||
|
data class InTheWay(
|
||||||
|
val uncommitted_files: Int = 0,
|
||||||
|
val untracked_files: Int = 0,
|
||||||
|
val unpushed_commits: Int = 0,
|
||||||
|
/** Lines added across the tracked changes, staged and unstaged together. */
|
||||||
|
val insertions: Int = 0,
|
||||||
|
/** Lines removed across the same tracked changes. */
|
||||||
|
val deletions: Int = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/names?count=N` — a batch of fresh session handles. Mirrors
|
||||||
|
* `maestro_protocol::NameBatch`. */
|
||||||
|
@Serializable
|
||||||
|
data class NameBatch(val names: List<String>)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a session's stored name (`metadata["name"]`) came from. Mirrors
|
||||||
|
* `maestro_protocol::NameSource`.
|
||||||
|
*
|
||||||
|
* Deserializes leniently: an unknown spelling decodes as [User], the settled
|
||||||
|
* variant — a name wrongly painted as settled is only a missed dimming; one
|
||||||
|
* wrongly painted as provisional invites overwriting a name a person chose.
|
||||||
|
*/
|
||||||
|
@Serializable(with = NameSourceSerializer::class)
|
||||||
|
enum class NameSource {
|
||||||
|
User,
|
||||||
|
Generated,
|
||||||
|
Haiku,
|
||||||
|
}
|
||||||
|
|
||||||
|
object NameSourceSerializer : KSerializer<NameSource> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("NameSource", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: NameSource) {
|
||||||
|
encoder.encodeString(value.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): NameSource =
|
||||||
|
when (decoder.decodeString()) {
|
||||||
|
"Generated" -> NameSource.Generated
|
||||||
|
"Haiku" -> NameSource.Haiku
|
||||||
|
else -> NameSource.User
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** One discovered git repository (or linked worktree) for `GET
|
||||||
|
* /api/projects`. Mirrors `maestro_protocol::Project`. */
|
||||||
|
@Serializable
|
||||||
|
data class Project(
|
||||||
|
/** Absolute path of the repository/worktree toplevel. */
|
||||||
|
val path: String,
|
||||||
|
/** Basename of [path]. */
|
||||||
|
val name: String,
|
||||||
|
val is_worktree: Boolean = false,
|
||||||
|
/** The main checkout this worktree belongs to; `null` on plain repos. */
|
||||||
|
val base_repo: String? = null,
|
||||||
|
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
|
||||||
|
* any directory inside it; `null` when neither is known. */
|
||||||
|
val last_activity: IsoTimestamp? = null,
|
||||||
|
/** Command last spawned in this project, from spawn history. */
|
||||||
|
val last_command: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/** Default for [Session.last_activity_at] / [Session.activity_since] when an
|
||||||
|
* older payload omits them — mirrors the daemon's Unix-epoch default. */
|
||||||
|
const val EPOCH: IsoTimestamp = "1970-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
/** A live or recently-dead tmux session managed by the daemon. Mirrors
|
||||||
|
* `maestro_protocol::Session`. */
|
||||||
|
@Serializable
|
||||||
|
data class Session(
|
||||||
|
val id: Uuid,
|
||||||
|
val cwd: String,
|
||||||
|
val command: String,
|
||||||
|
val env: Map<String, String> = emptyMap(),
|
||||||
|
val metadata: Map<String, JsonElement> = emptyMap(),
|
||||||
|
/** The worktree this session owns, when it was spawned with one. */
|
||||||
|
val worktree: SessionWorktree? = null,
|
||||||
|
val status: SessionStatus,
|
||||||
|
val created_at: IsoTimestamp,
|
||||||
|
/** Current foreground command in the pane (e.g. `claude`, `bash`), as the
|
||||||
|
* daemon's activity watcher observed it. `null` until observed. */
|
||||||
|
val foreground: String? = null,
|
||||||
|
/** Inferred activity dimension, orthogonal to [status]. */
|
||||||
|
val activity: ActivityState = ActivityState.Idle,
|
||||||
|
/** Short human string an agent reported alongside its activity — e.g. why
|
||||||
|
* it is in [ActivityState.Error]. `null` unless an agent reported one. */
|
||||||
|
val activity_detail: String? = null,
|
||||||
|
val last_activity_at: IsoTimestamp = EPOCH,
|
||||||
|
val activity_since: IsoTimestamp = EPOCH,
|
||||||
|
/** The pane's *current* working directory, distinct from [cwd] (the spawn
|
||||||
|
* dir). `null` until first observed. */
|
||||||
|
val current_cwd: String? = null,
|
||||||
|
/** What currently blocks an unforced kill of this session, as the
|
||||||
|
* daemon's watcher last probed it. `null` while clear or not yet probed. */
|
||||||
|
val blocked_work: InTheWay? = null,
|
||||||
|
/** When this session last became the pane a client was showing. `null`
|
||||||
|
* until the session has been focused at least once. */
|
||||||
|
val last_focused_at: IsoTimestamp? = null,
|
||||||
|
/** Terminal (PTY bytes) or structured (ACP rows). Fixed at spawn and
|
||||||
|
* immutable for the session's life. */
|
||||||
|
val view: SessionView = SessionView.Terminal,
|
||||||
|
/** The ACP session id the selected provider adapter returned from
|
||||||
|
* `session/new`. Only a [SessionView.Structured] session whose handshake
|
||||||
|
* succeeded has one. */
|
||||||
|
val acp_session_id: String? = null,
|
||||||
|
/** When `true`, the daemon auto-approves every ACP permission request for
|
||||||
|
* this session instead of surfacing them to the client. */
|
||||||
|
val auto_approve: Boolean = false,
|
||||||
|
/** Prompts queued on the daemon while a turn was in flight or the adapter
|
||||||
|
* was still booting. */
|
||||||
|
val queued_prompts: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class SessionStatus { Running, Dead }
|
||||||
|
|
||||||
|
/** A session's process shape: mirrors `maestro_protocol::SessionView`.
|
||||||
|
* `Terminal` is the default. */
|
||||||
|
@Serializable
|
||||||
|
enum class SessionView { Terminal, Structured }
|
||||||
|
|
||||||
|
/** A session's daemon-created worktree. `base_repo` is the repository's main
|
||||||
|
* checkout, which is what the session tree groups by. */
|
||||||
|
@Serializable
|
||||||
|
data class SessionWorktree(
|
||||||
|
val path: String,
|
||||||
|
val base_repo: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/** `POST /api/sessions` body. Mirrors
|
||||||
|
* `maestro_protocol::CreateSessionRequest`. */
|
||||||
|
@Serializable
|
||||||
|
data class CreateSessionRequest(
|
||||||
|
val cwd: String,
|
||||||
|
val command: String,
|
||||||
|
val env: Map<String, String> = emptyMap(),
|
||||||
|
val metadata: Map<String, JsonElement> = emptyMap(),
|
||||||
|
val worktree: WorktreeSpec? = null,
|
||||||
|
/** The process shape to spawn. Defaults to a `Terminal` session. */
|
||||||
|
val view: SessionView = SessionView.Terminal,
|
||||||
|
/** When `true`, the daemon auto-approves every ACP permission request for
|
||||||
|
* this structured session. */
|
||||||
|
val auto_approve: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Optional worktree request attached to a [CreateSessionRequest]. Mirrors
|
||||||
|
* `maestro_protocol::WorktreeSpec`. */
|
||||||
|
@Serializable
|
||||||
|
data class WorktreeSpec(
|
||||||
|
val base_repo: String,
|
||||||
|
val branch: String,
|
||||||
|
/** When `false`, [branch] already exists and is checked out as-is.
|
||||||
|
* Defaults to `true` (create a new branch). */
|
||||||
|
val create_branch: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `PATCH /api/sessions/:id` body: rename a live session. An empty [name]
|
||||||
|
* clears the custom one, so the card falls back to the command. Mirrors
|
||||||
|
* `maestro_protocol::RenameSessionRequest`. */
|
||||||
|
@Serializable
|
||||||
|
data class RenameSessionRequest(val name: String)
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/focus` body: a client reports whether its pane is
|
||||||
|
* the one currently showing this session. Mirrors
|
||||||
|
* `maestro_protocol::SetFocusRequest`. */
|
||||||
|
@Serializable
|
||||||
|
data class SetFocusRequest(val focused: Boolean)
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/** The semantic shape of a [TranscriptRow]. Mirrors
|
||||||
|
* `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
|
||||||
|
@Serializable
|
||||||
|
enum class TranscriptRowKind {
|
||||||
|
@SerialName("user") User,
|
||||||
|
@SerialName("agent_text") AgentText,
|
||||||
|
@SerialName("agent_thought") AgentThought,
|
||||||
|
@SerialName("tool_call") ToolCall,
|
||||||
|
@SerialName("tool_diff") ToolDiff,
|
||||||
|
@SerialName("approval") Approval,
|
||||||
|
@SerialName("plan") Plan,
|
||||||
|
@SerialName("notice") Notice,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One client-neutral item in a structured session transcript. [data]
|
||||||
|
* preserves the ACP payload verbatim without committing to an ACP
|
||||||
|
* dependency. Mirrors `maestro_protocol::TranscriptRow`. */
|
||||||
|
@Serializable
|
||||||
|
data class TranscriptRow(
|
||||||
|
/** Stable within one session, so later deltas can address this row. */
|
||||||
|
val id: String,
|
||||||
|
val kind: TranscriptRowKind,
|
||||||
|
val data: JsonElement,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The complete transcript at a durable event-log position — a structured
|
||||||
|
* session's first `WS /api/structured/:id` message. Mirrors
|
||||||
|
* `maestro_protocol::TranscriptSnapshot`. */
|
||||||
|
@Serializable
|
||||||
|
data class TranscriptSnapshot(
|
||||||
|
val cursor: Long,
|
||||||
|
val rows: List<TranscriptRow> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A change to one transcript row, addressed by [id]. Mirrors
|
||||||
|
* `maestro_protocol::TranscriptDelta`; tagged by a snake_case `type` field. */
|
||||||
|
@Serializable
|
||||||
|
sealed class TranscriptDelta {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("append")
|
||||||
|
data class Append(val row: TranscriptRow) : TranscriptDelta()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("patch")
|
||||||
|
data class Patch(val row: TranscriptRow) : TranscriptDelta()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("remove")
|
||||||
|
data class Remove(val id: String) : TranscriptDelta()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-turn indicators derived from the ACP stream — read verbatim, never
|
||||||
|
* re-derived client-side. Mirrors `maestro_protocol::TurnState`. */
|
||||||
|
@Serializable
|
||||||
|
data class TurnState(
|
||||||
|
val responding: Boolean = false,
|
||||||
|
val thinking: Boolean = false,
|
||||||
|
val active_tool_calls: List<String> = emptyList(),
|
||||||
|
/** Wall-clock start of the active prompt, so a reconnecting client keeps
|
||||||
|
* one continuous elapsed-time display instead of restarting at zero. */
|
||||||
|
val started_at: IsoTimestamp? = null,
|
||||||
|
val started_context_tokens: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole-session view derived from ACP notifications. Mirrors
|
||||||
|
* `maestro_protocol::ReducedState`.
|
||||||
|
*
|
||||||
|
* [pending_approval], [plan], [usage], [context] and [config_options] carry
|
||||||
|
* opaque ACP JSON — their shape is ACP's, not covered by this crate's own
|
||||||
|
* compatibility guarantee, so they are kept as [JsonElement] rather than
|
||||||
|
* modeled strongly.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ReducedState(
|
||||||
|
val turn: TurnState = TurnState(),
|
||||||
|
val pending_approval: JsonElement? = null,
|
||||||
|
val plan: JsonElement? = null,
|
||||||
|
val usage: JsonElement? = null,
|
||||||
|
val context: JsonElement? = null,
|
||||||
|
val model: String? = null,
|
||||||
|
val mode: String? = null,
|
||||||
|
val config_options: JsonElement? = null,
|
||||||
|
/** Prompts queued on the daemon while a turn is in flight. */
|
||||||
|
val queued_prompts: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Server messages on `WS /api/structured/:id`. Mirrors
|
||||||
|
* `maestro_protocol::StructuredEvent`; tagged by a snake_case `type` field. */
|
||||||
|
@Serializable
|
||||||
|
sealed class StructuredEvent {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("transcript_snapshot")
|
||||||
|
data class TranscriptSnapshotEvent(val snapshot: TranscriptSnapshot) : StructuredEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("reduced_state")
|
||||||
|
data class ReducedStateEvent(val state: ReducedState) : StructuredEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("projection_update")
|
||||||
|
data class ProjectionUpdate(
|
||||||
|
val transcript: TranscriptDelta? = null,
|
||||||
|
val state: ReducedState,
|
||||||
|
) : StructuredEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/prompt` body. */
|
||||||
|
@Serializable
|
||||||
|
data class StructuredPromptRequest(val prompt: String)
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/config` body: change one option
|
||||||
|
* advertised by a live ACP session. */
|
||||||
|
@Serializable
|
||||||
|
data class StructuredConfigRequest(val config_id: String, val value: JsonElement)
|
||||||
|
|
||||||
|
/** Operator decision for a pending ACP permission request
|
||||||
|
* (`POST /api/sessions/:id/structured/approvals/:nonce` body) — a bare JSON
|
||||||
|
* string on the wire, not `{decision: ...}`. Mirrors
|
||||||
|
* `maestro_protocol::StructuredApprovalDecision`. */
|
||||||
|
@Serializable
|
||||||
|
enum class StructuredApprovalDecision {
|
||||||
|
@SerialName("allow_once") AllowOnce,
|
||||||
|
@SerialName("allow_for_session") AllowForSession,
|
||||||
|
@SerialName("deny") Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response of `POST /api/sessions/:id/structured/attachments`. */
|
||||||
|
@Serializable
|
||||||
|
data class StructuredAttachmentResponse(val path: String)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package pt.naps.maestro.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Purple80 = Color(0xFFD0BCFF)
|
||||||
|
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||||
|
val Pink80 = Color(0xFFEFB8C8)
|
||||||
|
|
||||||
|
val Purple40 = Color(0xFF6650a4)
|
||||||
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
|
val Pink40 = Color(0xFF7D5260)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package pt.naps.maestro.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Purple80,
|
||||||
|
secondary = PurpleGrey80,
|
||||||
|
tertiary = Pink80,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Purple40,
|
||||||
|
secondary = PurpleGrey40,
|
||||||
|
tertiary = Pink40,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MaestroTheme(
|
||||||
|
darkTheme: Boolean = true,
|
||||||
|
dynamicColor: Boolean = false,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val useDarkTheme = darkTheme || isSystemInDarkTheme()
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
useDarkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pt.naps.maestro.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#1B1B1B"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#D0BCFF"
|
||||||
|
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Maestro</string>
|
||||||
|
<string name="placeholder_message">Maestro is not connected to a daemon yet.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
|
||||||
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package pt.naps.maestro
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Wire-format checks against fixtures mirrored from
|
||||||
|
* `crates/maestro-protocol/src/lib.rs`'s own test suite, so a Kotlin type
|
||||||
|
* that drifts from the Rust source of truth fails here first. */
|
||||||
|
class WireSerializationTest {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||||
|
|
||||||
|
private fun legacySessionJson(extra: String = "") = """
|
||||||
|
{"id":"00000000-0000-0000-0000-000000000001","cwd":"/tmp","command":"bash",
|
||||||
|
"status":"Running","created_at":"2026-05-24T12:00:00Z"$extra}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `legacy session payload defaults new fields`() {
|
||||||
|
val s = json.decodeFromString<Session>(legacySessionJson())
|
||||||
|
assertEquals(ActivityState.Idle, s.activity)
|
||||||
|
assertEquals(SessionView.Terminal, s.view)
|
||||||
|
assertNull(s.foreground)
|
||||||
|
assertNull(s.blocked_work)
|
||||||
|
assertEquals(EPOCH, s.last_activity_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown activity state degrades to Idle`() {
|
||||||
|
val s = json.decodeFromString<Session>(legacySessionJson(""","activity":"QuantumEntangled""""))
|
||||||
|
assertEquals(ActivityState.Idle, s.activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown name source degrades to User`() {
|
||||||
|
assertEquals(NameSource.User, json.decodeFromString<NameSource>("\"Rolled\""))
|
||||||
|
assertEquals(NameSource.Haiku, json.decodeFromString<NameSource>("\"Haiku\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `catalog event tags by type`() {
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"Lagged","skipped":3}""",
|
||||||
|
json.encodeToString<CatalogEvent>(CatalogEvent.Lagged(3)),
|
||||||
|
)
|
||||||
|
val removed = CatalogEvent.SessionRemoved("00000000-0000-0000-0000-000000000001")
|
||||||
|
assertEquals(removed, json.decodeFromString<CatalogEvent>(json.encodeToString<CatalogEvent>(removed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `client control tags by type`() {
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"Resize","cols":120,"rows":40}""",
|
||||||
|
json.encodeToString<ClientControl>(ClientControl.Resize(120, 40)),
|
||||||
|
)
|
||||||
|
assertEquals(ClientControl.Ping, json.decodeFromString<ClientControl>("""{"type":"Ping"}"""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `structured approval decision is a bare string`() {
|
||||||
|
assertEquals(
|
||||||
|
StructuredApprovalDecision.AllowOnce,
|
||||||
|
json.decodeFromString<StructuredApprovalDecision>("\"allow_once\""),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"\"allow_for_session\"",
|
||||||
|
json.encodeToString(StructuredApprovalDecision.AllowForSession),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `structured event tags snake_case`() {
|
||||||
|
val event = json.decodeFromString<StructuredEvent>(
|
||||||
|
"""{"type":"transcript_snapshot","snapshot":{"cursor":7,"rows":[]}}""",
|
||||||
|
)
|
||||||
|
assertTrue(event is StructuredEvent.TranscriptSnapshotEvent)
|
||||||
|
assertEquals(7L, (event as StructuredEvent.TranscriptSnapshotEvent).snapshot.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `transcript delta tags snake_case and round trips`() {
|
||||||
|
val row = TranscriptRow(
|
||||||
|
id = "tool:read-1",
|
||||||
|
kind = TranscriptRowKind.ToolCall,
|
||||||
|
data = json.parseToJsonElement("""{"name":"vendor.read"}"""),
|
||||||
|
)
|
||||||
|
val patch = TranscriptDelta.Patch(row)
|
||||||
|
val encoded = json.encodeToString<TranscriptDelta>(patch)
|
||||||
|
assertTrue(encoded.startsWith("""{"type":"patch","row":"""))
|
||||||
|
assertEquals(patch, json.decodeFromString<TranscriptDelta>(encoded))
|
||||||
|
assertEquals("\"agent_thought\"", json.encodeToString(TranscriptRowKind.AgentThought))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rename request encodes plainly`() {
|
||||||
|
assertEquals(
|
||||||
|
"""{"name":"amber-falcon"}""",
|
||||||
|
json.encodeToString(RenameSessionRequest("amber-falcon")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `api error decodes nested unsaved work with defaults`() {
|
||||||
|
val apiError = json.decodeFromString<ApiError>(
|
||||||
|
"""{"error":{"code":"unsaved_work","message":"nope","unsaved_work":{"uncommitted_files":3}}}""",
|
||||||
|
)
|
||||||
|
assertEquals(3, apiError.error.unsaved_work?.uncommitted_files)
|
||||||
|
assertEquals(0, apiError.error.unsaved_work?.unpushed_commits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.5.2"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.13.1"
|
||||||
|
lifecycleRuntimeKtx = "2.8.6"
|
||||||
|
activityCompose = "1.9.2"
|
||||||
|
composeBom = "2024.09.03"
|
||||||
|
navigationCompose = "2.8.2"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
kotlinxSerializationJson = "1.7.3"
|
||||||
|
kotlinxCoroutines = "1.9.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
junit = { group = "junit", name = "junit", version = "4.13.2" }
|
||||||
|
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# issues, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated via the Gradle "wrapper" task
|
||||||
|
# (see https://docs.gradle.org/current/userguide/gradle_wrapper.html)
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments that
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# double quotes to make sure that they get re-expanded; and
|
||||||
|
# * put everything else in single quotes, so that it's not re-expanded.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-A-Za-z0-9_+.]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+89
@@ -0,0 +1,89 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" -Dorg.gradle.appname=%APP_BASE_NAME% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Maestro"
|
||||||
|
|
||||||
|
include(":app")
|
||||||
@@ -5,11 +5,12 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
description = "ACP client role: one claude-code-acp child per structured session"
|
description = "ACP client role: one provider adapter child per structured session"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
agent-client-protocol.workspace = true
|
agent-client-protocol.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
+606
-59
@@ -1,5 +1,5 @@
|
|||||||
//! ACP client role for the daemon (#134): one `claude-code-acp` child per
|
//! ACP client role for the daemon: one provider adapter child per structured
|
||||||
//! structured session.
|
//! session.
|
||||||
//!
|
//!
|
||||||
//! The daemon is the client; the adapter is the agent. [`spawn`] starts the
|
//! The daemon is the client; the adapter is the agent. [`spawn`] starts the
|
||||||
//! adapter as a child process with stdio pipes, in the session's `cwd` with
|
//! adapter as a child process with stdio pipes, in the session's `cwd` with
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
//!
|
//!
|
||||||
//! **The handshake declares almost nothing.** `initialize` reports
|
//! **The handshake declares almost nothing.** `initialize` reports
|
||||||
//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the
|
//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the
|
||||||
//! adapter uses Claude Code's own Read / Write / Bash instead of asking the
|
//! adapter uses the provider's own file and command tools instead of asking
|
||||||
//! daemon to act on its behalf — exactly what already happens in the
|
//! the daemon to act on its behalf — exactly what already happens in the
|
||||||
//! terminal view. `session/request_permission` is the one client method that
|
//! terminal view. `session/request_permission` is the one client method with
|
||||||
//! gets a real implementation, in #139; here it is a stub that declines.
|
//! a real implementation.
|
||||||
//!
|
//!
|
||||||
//! Every `session/update` notification the adapter sends lands on an
|
//! Every `session/update` notification the adapter sends lands on an
|
||||||
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
|
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
|
||||||
@@ -42,29 +42,80 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use agent_client_protocol::schema::v1::{
|
use agent_client_protocol::schema::v1::{
|
||||||
CancelNotification, ClientCapabilities, FileSystemCapabilities, Implementation,
|
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
|
||||||
InitializeRequest, LoadSessionRequest, NewSessionRequest, RequestPermissionOutcome,
|
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
|
||||||
RequestPermissionRequest, RequestPermissionResponse, SessionId, SessionNotification,
|
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
|
||||||
|
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
|
||||||
|
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
|
||||||
|
SetSessionConfigOptionRequest, SetSessionModeRequest,
|
||||||
};
|
};
|
||||||
use agent_client_protocol::schema::ProtocolVersion;
|
use agent_client_protocol::schema::ProtocolVersion;
|
||||||
use agent_client_protocol::{ByteStreams, Client};
|
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::io::AsyncBufReadExt;
|
use tokio::io::AsyncBufReadExt;
|
||||||
use tokio::sync::{mpsc, oneshot, watch};
|
use tokio::sync::{mpsc, oneshot, watch};
|
||||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||||
|
|
||||||
pub use agent_client_protocol::schema::v1 as schema;
|
pub use agent_client_protocol::schema::v1 as schema;
|
||||||
|
|
||||||
/// The adapter binary, resolved on `PATH`.
|
/// Item on the updates channel. Notifications and turn-done markers share
|
||||||
pub const ADAPTER_BIN: &str = "claude-code-acp";
|
/// the same FIFO so the event pump processes every notification for a turn
|
||||||
|
/// before seeing its done signal.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AcpUpdate {
|
||||||
|
/// A raw `session/update` notification from the adapter.
|
||||||
|
Notification(Box<SessionNotification>),
|
||||||
|
/// The prompt response for `turn` arrived. All notifications for this
|
||||||
|
/// turn precede this marker on the wire, and therefore in this channel.
|
||||||
|
/// The event pump must fire `done` after persisting every preceding
|
||||||
|
/// notification.
|
||||||
|
TurnDone {
|
||||||
|
turn: u64,
|
||||||
|
result: Result<(), String>,
|
||||||
|
done: oneshot::Sender<Result<(), String>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// How to install the adapter when it is missing. Named in the error so an
|
/// ACP adapter selected from the structured session's resolved command.
|
||||||
/// operator staring at a failed session knows what to run.
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AcpAdapter {
|
||||||
|
ClaudeCode,
|
||||||
|
Codex,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AcpAdapter {
|
||||||
|
pub const fn binary(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::ClaudeCode => "claude-code-acp",
|
||||||
|
Self::Codex => "codex-acp",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn install_command(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::ClaudeCode => "npm install -g --prefix ~/.local @zed-industries/claude-code-acp",
|
||||||
|
Self::Codex => "npm install -g --prefix ~/.local @agentclientprotocol/codex-acp",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AcpAdapter {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::ClaudeCode => "Claude Code",
|
||||||
|
Self::Codex => "Codex",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatibility aliases for Claude Code callers.
|
||||||
|
pub const ADAPTER_BIN: &str = "claude-code-acp";
|
||||||
pub const INSTALL_COMMAND: &str =
|
pub const INSTALL_COMMAND: &str =
|
||||||
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
|
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
|
||||||
|
|
||||||
/// How long `initialize` + `session/new` may take before the spawn is
|
/// How long `initialize` + `session/new` may take before the spawn is
|
||||||
/// declared failed. The real adapter boots a node process and the Claude
|
/// declared failed. The real adapters boot a node process and their provider
|
||||||
/// Code SDK behind it; a slow machine takes seconds, never a minute.
|
/// client behind it; a slow machine takes seconds, never a minute.
|
||||||
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
|
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
/// How long [`AcpSession::terminate`] waits after `session/cancel` before
|
/// How long [`AcpSession::terminate`] waits after `session/cancel` before
|
||||||
@@ -77,6 +128,75 @@ pub const TERM_GRACE: Duration = Duration::from_secs(2);
|
|||||||
|
|
||||||
/// Bytes of the child's stderr kept for the failure message.
|
/// Bytes of the child's stderr kept for the failure message.
|
||||||
const STDERR_TAIL_BYTES: usize = 4096;
|
const STDERR_TAIL_BYTES: usize = 4096;
|
||||||
|
const LEGACY_MODEL_CONFIG_ID: &str = "__maestro_legacy_model";
|
||||||
|
const LEGACY_MODE_CONFIG_ID: &str = "__maestro_legacy_mode";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||||
|
#[request(method = "session/new", response = ExtendedNewSessionResponse)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExtendedNewSessionRequest {
|
||||||
|
cwd: PathBuf,
|
||||||
|
mcp_servers: Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExtendedNewSessionResponse {
|
||||||
|
session_id: SessionId,
|
||||||
|
#[serde(default)]
|
||||||
|
modes: Option<SessionModeState>,
|
||||||
|
#[serde(default)]
|
||||||
|
config_options: Option<Vec<SessionConfigOption>>,
|
||||||
|
#[serde(default)]
|
||||||
|
models: Option<LegacyModelState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||||
|
#[request(method = "session/load", response = ExtendedLoadSessionResponse)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExtendedLoadSessionRequest {
|
||||||
|
session_id: SessionId,
|
||||||
|
cwd: PathBuf,
|
||||||
|
mcp_servers: Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExtendedLoadSessionResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
modes: Option<SessionModeState>,
|
||||||
|
#[serde(default)]
|
||||||
|
config_options: Option<Vec<SessionConfigOption>>,
|
||||||
|
#[serde(default)]
|
||||||
|
models: Option<LegacyModelState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LegacyModelState {
|
||||||
|
current_model_id: String,
|
||||||
|
available_models: Vec<LegacyModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LegacyModel {
|
||||||
|
model_id: String,
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||||
|
#[request(method = "session/set_model", response = LegacySetModelResponse)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LegacySetModelRequest {
|
||||||
|
session_id: SessionId,
|
||||||
|
model_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||||
|
struct LegacySetModelResponse {}
|
||||||
|
|
||||||
/// What [`spawn`] needs to start one adapter.
|
/// What [`spawn`] needs to start one adapter.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -93,6 +213,8 @@ pub struct SpawnConfig {
|
|||||||
/// instead of opening a fresh one. The adapter must advertise
|
/// instead of opening a fresh one. The adapter must advertise
|
||||||
/// `loadSession`; the handshake fails otherwise.
|
/// `loadSession`; the handshake fails otherwise.
|
||||||
pub load_session: Option<String>,
|
pub load_session: Option<String>,
|
||||||
|
/// The provider-specific adapter to launch.
|
||||||
|
pub adapter: AcpAdapter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpawnConfig {
|
impl SpawnConfig {
|
||||||
@@ -102,6 +224,7 @@ impl SpawnConfig {
|
|||||||
env,
|
env,
|
||||||
handshake_timeout: HANDSHAKE_TIMEOUT,
|
handshake_timeout: HANDSHAKE_TIMEOUT,
|
||||||
load_session: None,
|
load_session: None,
|
||||||
|
adapter: AcpAdapter::ClaudeCode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,37 +233,59 @@ impl SpawnConfig {
|
|||||||
self.load_session = Some(session_id.into());
|
self.load_session = Some(session_id.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn adapter(mut self, adapter: AcpAdapter) -> Self {
|
||||||
|
self.adapter = adapter;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A blocked ACP permission request delivered to the daemon.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PermissionRequest {
|
||||||
|
/// The prompt turn that was active when ACP delivered this callback.
|
||||||
|
pub turn: u64,
|
||||||
|
pub request: RequestPermissionRequest,
|
||||||
|
pub response: oneshot::Sender<RequestPermissionOutcome>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AcpError {
|
pub enum AcpError {
|
||||||
/// The adapter is not on `PATH`. The message names the install command
|
/// The adapter is not on `PATH`. The message names the install command
|
||||||
/// so the failed session is actionable rather than silently dead.
|
/// so the failed session is actionable rather than silently dead.
|
||||||
#[error("{ADAPTER_BIN} not found on PATH; install it with: {INSTALL_COMMAND}")]
|
#[error("{adapter} ACP adapter ({bin}) not found on PATH; install it with: {install_command}", bin = adapter.binary(), install_command = adapter.install_command())]
|
||||||
AdapterMissing,
|
AdapterMissing { adapter: AcpAdapter },
|
||||||
#[error("failed to start {ADAPTER_BIN}: {0}")]
|
#[error("failed to start {adapter} ACP adapter ({bin}): {source}", bin = adapter.binary())]
|
||||||
Spawn(#[source] std::io::Error),
|
Spawn {
|
||||||
|
adapter: AcpAdapter,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
/// The adapter answered the handshake with an error.
|
/// The adapter answered the handshake with an error.
|
||||||
#[error("ACP handshake failed: {0}")]
|
#[error("ACP handshake failed: {0}")]
|
||||||
Handshake(String),
|
Handshake(String),
|
||||||
/// The adapter went away before the handshake finished. Carries the
|
/// The adapter went away before the handshake finished. Carries the
|
||||||
/// tail of its stderr, which is where a missing Claude login shows up.
|
/// tail of its stderr, which is where a missing Claude login shows up.
|
||||||
#[error("{ADAPTER_BIN} exited during the ACP handshake ({status}){stderr}")]
|
#[error("{adapter} ACP adapter ({bin}) exited during the ACP handshake ({status}){stderr}", bin = adapter.binary())]
|
||||||
ExitedEarly { status: String, stderr: String },
|
ExitedEarly {
|
||||||
|
adapter: AcpAdapter,
|
||||||
|
status: String,
|
||||||
|
stderr: String,
|
||||||
|
},
|
||||||
#[error("ACP handshake timed out after {0:?}")]
|
#[error("ACP handshake timed out after {0:?}")]
|
||||||
HandshakeTimeout(Duration),
|
HandshakeTimeout(Duration),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the
|
/// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the
|
||||||
/// session env's `PATH` when it sets one, the daemon's own otherwise.
|
/// session env's `PATH` when it sets one, the daemon's own otherwise.
|
||||||
pub fn resolve_adapter(env: &HashMap<String, String>) -> Option<PathBuf> {
|
pub fn resolve_adapter(adapter: AcpAdapter, env: &HashMap<String, String>) -> Option<PathBuf> {
|
||||||
let path: OsString = match env.get("PATH") {
|
let path: OsString = match env.get("PATH") {
|
||||||
Some(p) => OsString::from(p),
|
Some(p) => OsString::from(p),
|
||||||
None => std::env::var_os("PATH")?,
|
None => std::env::var_os("PATH")?,
|
||||||
};
|
};
|
||||||
std::env::split_paths(&path)
|
std::env::split_paths(&path)
|
||||||
.filter(|dir| !dir.as_os_str().is_empty())
|
.filter(|dir| !dir.as_os_str().is_empty())
|
||||||
.map(|dir| dir.join(ADAPTER_BIN))
|
.map(|dir| dir.join(adapter.binary()))
|
||||||
.find(|candidate| is_executable(candidate))
|
.find(|candidate| is_executable(candidate))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,11 +298,53 @@ fn is_executable(path: &Path) -> bool {
|
|||||||
|
|
||||||
enum Command {
|
enum Command {
|
||||||
Cancel,
|
Cancel,
|
||||||
|
Prompt {
|
||||||
|
text: String,
|
||||||
|
turn: u64,
|
||||||
|
done: oneshot::Sender<Result<(), String>>,
|
||||||
|
},
|
||||||
|
SetConfig {
|
||||||
|
config_id: String,
|
||||||
|
value: SessionConfigOptionValue,
|
||||||
|
done: oneshot::Sender<Result<(), String>>,
|
||||||
|
},
|
||||||
Close,
|
Close,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ConnectionConfig {
|
||||||
|
cwd: PathBuf,
|
||||||
|
load_session: Option<String>,
|
||||||
|
adapter: AcpAdapter,
|
||||||
|
}
|
||||||
|
|
||||||
enum ChildCommand {
|
enum ChildCommand {
|
||||||
Signal(libc::c_int),
|
Signal(libc::c_int),
|
||||||
|
Reap(oneshot::Sender<bool>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct PermissionTurnState {
|
||||||
|
active: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionTurnState {
|
||||||
|
fn begin(&mut self, turn: u64) {
|
||||||
|
self.active = Some(turn);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_active(&mut self) {
|
||||||
|
self.active = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, turn: u64) {
|
||||||
|
if self.active == Some(turn) {
|
||||||
|
self.active = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn callback_turn(&self) -> Option<u64> {
|
||||||
|
self.active
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why the handshake did not produce a session id.
|
/// Why the handshake did not produce a session id.
|
||||||
@@ -185,7 +372,8 @@ pub struct AcpSession {
|
|||||||
pid: u32,
|
pid: u32,
|
||||||
commands: mpsc::UnboundedSender<Command>,
|
commands: mpsc::UnboundedSender<Command>,
|
||||||
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
||||||
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
|
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
|
||||||
|
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
||||||
exit: watch::Receiver<Option<ExitStatus>>,
|
exit: watch::Receiver<Option<ExitStatus>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,23 +394,76 @@ impl AcpSession {
|
|||||||
self.exit.borrow().is_some()
|
self.exit.borrow().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reap the child if it has already exited.
|
||||||
|
pub async fn reap_if_exited(&self) -> bool {
|
||||||
|
if self.has_exited() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let (sent, received) = oneshot::channel();
|
||||||
|
if self.child_commands.send(ChildCommand::Reap(sent)).is_err() {
|
||||||
|
return self.has_exited();
|
||||||
|
}
|
||||||
|
received.await.unwrap_or_else(|_| self.has_exited())
|
||||||
|
}
|
||||||
|
|
||||||
/// Wait for the child to exit.
|
/// Wait for the child to exit.
|
||||||
pub async fn wait(&self) -> Option<ExitStatus> {
|
pub async fn wait(&self) -> Option<ExitStatus> {
|
||||||
wait_exit(&self.exit).await
|
wait_exit(&self.exit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stream of raw `session/update` notifications, handed out once.
|
/// The stream of updates (notifications + turn-done markers), handed out
|
||||||
/// Notifications that arrived before the take are still queued on it.
|
/// once. Items that arrived before the take are still queued.
|
||||||
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<SessionNotification>> {
|
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<AcpUpdate>> {
|
||||||
self.updates.lock().expect("updates mutex").take()
|
self.updates.lock().expect("updates mutex").take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn take_permission_requests(&self) -> Option<mpsc::UnboundedReceiver<PermissionRequest>> {
|
||||||
|
self.permission_requests
|
||||||
|
.lock()
|
||||||
|
.expect("permission requests")
|
||||||
|
.take()
|
||||||
|
}
|
||||||
|
|
||||||
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
|
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
|
||||||
/// child is already gone, which is the outcome cancel was after.
|
/// child is already gone, which is the outcome cancel was after.
|
||||||
pub fn cancel(&self) {
|
pub fn cancel(&self) {
|
||||||
let _ = self.commands.send(Command::Cancel);
|
let _ = self.commands.send(Command::Cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a user prompt and receive completion once the agent ends the turn.
|
||||||
|
pub fn prompt(
|
||||||
|
&self,
|
||||||
|
text: String,
|
||||||
|
turn: u64,
|
||||||
|
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
||||||
|
let (done_tx, done_rx) = oneshot::channel();
|
||||||
|
self.commands
|
||||||
|
.send(Command::Prompt {
|
||||||
|
text,
|
||||||
|
turn,
|
||||||
|
done: done_tx,
|
||||||
|
})
|
||||||
|
.map_err(|_| "ACP connection is closed".to_string())?;
|
||||||
|
Ok(done_rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change one option advertised by the ACP agent.
|
||||||
|
pub fn set_config(
|
||||||
|
&self,
|
||||||
|
config_id: String,
|
||||||
|
value: SessionConfigOptionValue,
|
||||||
|
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
||||||
|
let (done_tx, done_rx) = oneshot::channel();
|
||||||
|
self.commands
|
||||||
|
.send(Command::SetConfig {
|
||||||
|
config_id,
|
||||||
|
value,
|
||||||
|
done: done_tx,
|
||||||
|
})
|
||||||
|
.map_err(|_| "ACP connection is closed".to_string())?;
|
||||||
|
Ok(done_rx)
|
||||||
|
}
|
||||||
|
|
||||||
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
|
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
|
||||||
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
|
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
|
||||||
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
|
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
|
||||||
@@ -263,7 +504,9 @@ impl Drop for AcpSession {
|
|||||||
/// Fails without a running child: on any error the process is already
|
/// Fails without a running child: on any error the process is already
|
||||||
/// signalled, so a failed session never leaves a stray adapter behind.
|
/// signalled, so a failed session never leaves a stray adapter behind.
|
||||||
pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||||
let adapter = resolve_adapter(&config.env).ok_or(AcpError::AdapterMissing)?;
|
let provider = config.adapter;
|
||||||
|
let adapter = resolve_adapter(provider, &config.env)
|
||||||
|
.ok_or(AcpError::AdapterMissing { adapter: provider })?;
|
||||||
|
|
||||||
let mut cmd = tokio::process::Command::new(&adapter);
|
let mut cmd = tokio::process::Command::new(&adapter);
|
||||||
cmd.current_dir(&config.cwd)
|
cmd.current_dir(&config.cwd)
|
||||||
@@ -291,28 +534,42 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut child = cmd.spawn().map_err(AcpError::Spawn)?;
|
let mut child = cmd.spawn().map_err(|source| AcpError::Spawn {
|
||||||
|
adapter: provider,
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
let pid = child.id().expect("child pid is known before wait");
|
let pid = child.id().expect("child pid is known before wait");
|
||||||
let stdin = child.stdin.take().expect("piped stdin");
|
let stdin = child.stdin.take().expect("piped stdin");
|
||||||
let stdout = child.stdout.take().expect("piped stdout");
|
let stdout = child.stdout.take().expect("piped stdout");
|
||||||
let stderr = child.stderr.take().expect("piped stderr");
|
let stderr = child.stderr.take().expect("piped stderr");
|
||||||
|
|
||||||
let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new()));
|
let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new()));
|
||||||
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone()));
|
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone(), provider));
|
||||||
|
|
||||||
let (exit_tx, exit_rx) = watch::channel(None);
|
let (exit_tx, exit_rx) = watch::channel(None);
|
||||||
let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
|
let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
|
||||||
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx));
|
tokio::spawn(supervise_child(
|
||||||
|
child,
|
||||||
|
pid,
|
||||||
|
child_commands_rx,
|
||||||
|
exit_tx,
|
||||||
|
provider,
|
||||||
|
));
|
||||||
|
|
||||||
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
||||||
|
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
|
||||||
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
|
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
|
||||||
let (ready_tx, ready_rx) = oneshot::channel();
|
let (ready_tx, ready_rx) = oneshot::channel();
|
||||||
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
||||||
tokio::spawn(run_connection(
|
tokio::spawn(run_connection(
|
||||||
transport,
|
transport,
|
||||||
config.cwd.clone(),
|
ConnectionConfig {
|
||||||
config.load_session.clone(),
|
cwd: config.cwd.clone(),
|
||||||
|
load_session: config.load_session.clone(),
|
||||||
|
adapter: provider,
|
||||||
|
},
|
||||||
updates_tx,
|
updates_tx,
|
||||||
|
permission_tx,
|
||||||
commands_rx,
|
commands_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
));
|
));
|
||||||
@@ -326,6 +583,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
commands: commands_tx,
|
commands: commands_tx,
|
||||||
child_commands: child_commands_tx,
|
child_commands: child_commands_tx,
|
||||||
updates: Mutex::new(Some(updates_rx)),
|
updates: Mutex::new(Some(updates_rx)),
|
||||||
|
permission_requests: Mutex::new(Some(permission_rx)),
|
||||||
exit: exit_rx,
|
exit: exit_rx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -342,6 +600,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
AcpError::ExitedEarly {
|
AcpError::ExitedEarly {
|
||||||
|
adapter: provider,
|
||||||
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
|
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
|
||||||
stderr: if stderr.is_empty() {
|
stderr: if stderr.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -376,12 +635,13 @@ async fn supervise_child(
|
|||||||
pid: u32,
|
pid: u32,
|
||||||
mut commands: mpsc::UnboundedReceiver<ChildCommand>,
|
mut commands: mpsc::UnboundedReceiver<ChildCommand>,
|
||||||
exit_tx: watch::Sender<Option<ExitStatus>>,
|
exit_tx: watch::Sender<Option<ExitStatus>>,
|
||||||
|
adapter: AcpAdapter,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
status = child.wait() => {
|
status = child.wait() => {
|
||||||
let status = status.ok();
|
let status = status.ok();
|
||||||
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
|
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||||
let _ = exit_tx.send(status);
|
let _ = exit_tx.send(status);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -392,13 +652,28 @@ async fn supervise_child(
|
|||||||
// so its PID cannot be reused in that window.
|
// so its PID cannot be reused in that window.
|
||||||
unsafe { libc::kill(pid as libc::pid_t, sig) };
|
unsafe { libc::kill(pid as libc::pid_t, sig) };
|
||||||
}
|
}
|
||||||
|
Some(ChildCommand::Reap(done)) => match child.try_wait() {
|
||||||
|
Ok(Some(status)) => {
|
||||||
|
let _ = done.send(true);
|
||||||
|
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||||
|
let _ = exit_tx.send(Some(status));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
let _ = done.send(false);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(pid, %error, provider = %adapter, "could not reap ACP adapter");
|
||||||
|
let _ = done.send(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
None => {
|
None => {
|
||||||
// The session vanished without a command receiver. This
|
// The session vanished without a command receiver. This
|
||||||
// task still owns the unreaped child, so this cannot hit
|
// task still owns the unreaped child, so this cannot hit
|
||||||
// a reused PID.
|
// a reused PID.
|
||||||
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
|
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
|
||||||
let status = child.wait().await.ok();
|
let status = child.wait().await.ok();
|
||||||
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
|
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||||
let _ = exit_tx.send(status);
|
let _ = exit_tx.send(status);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -417,11 +692,106 @@ fn client_capabilities() -> ClientCapabilities {
|
|||||||
.terminal(false)
|
.terminal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn advertised_config_options(
|
||||||
|
mut options: Vec<SessionConfigOption>,
|
||||||
|
modes: Option<&SessionModeState>,
|
||||||
|
models: Option<&LegacyModelState>,
|
||||||
|
) -> Vec<SessionConfigOption> {
|
||||||
|
let has_model = options
|
||||||
|
.iter()
|
||||||
|
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Model)));
|
||||||
|
let has_mode = options
|
||||||
|
.iter()
|
||||||
|
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Mode)));
|
||||||
|
if let Some(models) = models.filter(|_| !has_model) {
|
||||||
|
let mut choices = models
|
||||||
|
.available_models
|
||||||
|
.iter()
|
||||||
|
.map(|model| {
|
||||||
|
let mut option =
|
||||||
|
SessionConfigSelectOption::new(model.model_id.clone(), &model.name);
|
||||||
|
if model.model_id == "default" {
|
||||||
|
option.name = "Opus (default)".into();
|
||||||
|
}
|
||||||
|
option.description.clone_from(&model.description);
|
||||||
|
option
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !models
|
||||||
|
.available_models
|
||||||
|
.iter()
|
||||||
|
.any(|model| model.model_id == "fable")
|
||||||
|
{
|
||||||
|
choices.push(SessionConfigSelectOption::new("fable", "Fable"));
|
||||||
|
}
|
||||||
|
options.push(
|
||||||
|
SessionConfigOption::select(
|
||||||
|
LEGACY_MODEL_CONFIG_ID,
|
||||||
|
"Model",
|
||||||
|
models.current_model_id.clone(),
|
||||||
|
choices,
|
||||||
|
)
|
||||||
|
.category(SessionConfigOptionCategory::Model),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(modes) = modes.filter(|_| !has_mode) {
|
||||||
|
let choices = modes
|
||||||
|
.available_modes
|
||||||
|
.iter()
|
||||||
|
.map(|mode| {
|
||||||
|
let mut option = SessionConfigSelectOption::new(mode.id.0.clone(), &mode.name);
|
||||||
|
option.description.clone_from(&mode.description);
|
||||||
|
option
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
options.push(
|
||||||
|
SessionConfigOption::select(
|
||||||
|
LEGACY_MODE_CONFIG_ID,
|
||||||
|
"Mode",
|
||||||
|
modes.current_mode_id.0.clone(),
|
||||||
|
choices,
|
||||||
|
)
|
||||||
|
.category(SessionConfigOptionCategory::Mode),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
options
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_config_value(
|
||||||
|
options: &Arc<Mutex<Vec<SessionConfigOption>>>,
|
||||||
|
config_id: &str,
|
||||||
|
value: &SessionConfigOptionValue,
|
||||||
|
) -> Vec<SessionConfigOption> {
|
||||||
|
let mut options = options.lock().expect("config options");
|
||||||
|
if let Some(option) = options
|
||||||
|
.iter_mut()
|
||||||
|
.find(|option| option.id.0.as_ref() == config_id)
|
||||||
|
{
|
||||||
|
match (&mut option.kind, value) {
|
||||||
|
(SessionConfigKind::Select(select), SessionConfigOptionValue::ValueId { value }) => {
|
||||||
|
select.current_value = value.clone();
|
||||||
|
}
|
||||||
|
(SessionConfigKind::Boolean(boolean), SessionConfigOptionValue::Boolean { value }) => {
|
||||||
|
boolean.current_value = *value;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> SessionNotification {
|
||||||
|
SessionNotification::new(
|
||||||
|
SessionId::new(session_id),
|
||||||
|
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_connection<T>(
|
async fn run_connection<T>(
|
||||||
transport: T,
|
transport: T,
|
||||||
cwd: PathBuf,
|
config: ConnectionConfig,
|
||||||
load_session: Option<String>,
|
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
|
||||||
updates_tx: mpsc::UnboundedSender<SessionNotification>,
|
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
||||||
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
||||||
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
||||||
) where
|
) where
|
||||||
@@ -433,6 +803,11 @@ async fn run_connection<T>(
|
|||||||
// history the event log already holds (#135).
|
// history the event log already holds (#135).
|
||||||
let replaying = Arc::new(AtomicBool::new(false));
|
let replaying = Arc::new(AtomicBool::new(false));
|
||||||
let replaying_seen = replaying.clone();
|
let replaying_seen = replaying.clone();
|
||||||
|
// ACP callbacks have no turn id. The daemon never queues a later prompt
|
||||||
|
// until a cancelled prompt settles, so this active turn is unambiguous.
|
||||||
|
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
|
||||||
|
let permission_turn_seen = permission_turn.clone();
|
||||||
|
let received_updates = updates_tx.clone();
|
||||||
let result = Client
|
let result = Client
|
||||||
.builder()
|
.builder()
|
||||||
.name("maestro")
|
.name("maestro")
|
||||||
@@ -443,22 +818,37 @@ async fn run_connection<T>(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// A dropped receiver only means nobody wants updates any more.
|
// A dropped receiver only means nobody wants updates any more.
|
||||||
let _ = updates_tx.send(notification);
|
let _ = received_updates.send(AcpUpdate::Notification(Box::new(notification)));
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
agent_client_protocol::on_receive_notification!(),
|
agent_client_protocol::on_receive_notification!(),
|
||||||
)
|
)
|
||||||
.on_receive_request(
|
.on_receive_request(
|
||||||
async move |request: RequestPermissionRequest, responder, _cx| {
|
async move |request: RequestPermissionRequest, responder, _cx| {
|
||||||
// Stub until #139: decline rather than grant something the
|
let turn = permission_turn_seen
|
||||||
// operator never saw.
|
.lock()
|
||||||
tracing::warn!(
|
.expect("permission turn state")
|
||||||
session = %request.session_id,
|
.callback_turn();
|
||||||
"session/request_permission declined: not implemented yet (#139)"
|
let Some(turn) = turn else {
|
||||||
);
|
return responder.respond(RequestPermissionResponse::new(
|
||||||
responder.respond(RequestPermissionResponse::new(
|
RequestPermissionOutcome::Cancelled,
|
||||||
RequestPermissionOutcome::Cancelled,
|
));
|
||||||
))
|
};
|
||||||
|
let (response, received) = oneshot::channel();
|
||||||
|
if permission_requests
|
||||||
|
.send(PermissionRequest {
|
||||||
|
turn,
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return responder.respond(RequestPermissionResponse::new(
|
||||||
|
RequestPermissionOutcome::Cancelled,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let outcome = received.await.unwrap_or(RequestPermissionOutcome::Cancelled);
|
||||||
|
responder.respond(RequestPermissionResponse::new(outcome))
|
||||||
},
|
},
|
||||||
agent_client_protocol::on_receive_request!(),
|
agent_client_protocol::on_receive_request!(),
|
||||||
)
|
)
|
||||||
@@ -473,11 +863,13 @@ async fn run_connection<T>(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let session_id = match load_session {
|
let (session_id, modes, initial_options, models) = match config.load_session {
|
||||||
Some(existing) => {
|
Some(existing) => {
|
||||||
if !initialized.agent_capabilities.load_session {
|
if !initialized.agent_capabilities.load_session {
|
||||||
let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
|
let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
|
||||||
"{ADAPTER_BIN} does not advertise loadSession; cannot resume session {existing}"
|
"{} ACP adapter ({}) does not advertise loadSession; cannot resume session {existing}",
|
||||||
|
config.adapter,
|
||||||
|
config.adapter.binary()
|
||||||
))));
|
))));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -485,7 +877,11 @@ async fn run_connection<T>(
|
|||||||
let (loaded_tx, loaded_rx) = oneshot::channel();
|
let (loaded_tx, loaded_rx) = oneshot::channel();
|
||||||
let replaying_done = replaying.clone();
|
let replaying_done = replaying.clone();
|
||||||
if let Err(e) = cx
|
if let Err(e) = cx
|
||||||
.send_request(LoadSessionRequest::new(existing.clone(), cwd))
|
.send_request(ExtendedLoadSessionRequest {
|
||||||
|
session_id: SessionId::new(existing.as_str()),
|
||||||
|
cwd: config.cwd,
|
||||||
|
mcp_servers: vec![],
|
||||||
|
})
|
||||||
.on_receiving_result(move |result| {
|
.on_receiving_result(move |result| {
|
||||||
replaying_done.store(false, Ordering::SeqCst);
|
replaying_done.store(false, Ordering::SeqCst);
|
||||||
async move {
|
async move {
|
||||||
@@ -497,8 +893,8 @@ async fn run_connection<T>(
|
|||||||
let _ = ready_tx.send(Err(ready_error(e)));
|
let _ = ready_tx.send(Err(ready_error(e)));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
match loaded_rx.await {
|
let loaded = match loaded_rx.await {
|
||||||
Ok(Ok(_)) => {}
|
Ok(Ok(loaded)) => loaded,
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
let _ = ready_tx.send(Err(ready_error(e)));
|
let _ = ready_tx.send(Err(ready_error(e)));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -509,31 +905,181 @@ async fn run_connection<T>(
|
|||||||
)));
|
)));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
existing
|
(existing, loaded.modes, loaded.config_options.unwrap_or_default(), loaded.models)
|
||||||
}
|
}
|
||||||
None => match cx
|
None => match cx
|
||||||
.send_request(NewSessionRequest::new(cwd))
|
.send_request(ExtendedNewSessionRequest {
|
||||||
|
cwd: config.cwd,
|
||||||
|
mcp_servers: vec![],
|
||||||
|
})
|
||||||
.block_task()
|
.block_task()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(created) => created.session_id.0.to_string(),
|
Ok(created) => (
|
||||||
|
created.session_id.0.to_string(),
|
||||||
|
created.modes,
|
||||||
|
created.config_options.unwrap_or_default(),
|
||||||
|
created.models,
|
||||||
|
),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = ready_tx.send(Err(ready_error(e)));
|
let _ = ready_tx.send(Err(ready_error(e)));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
let config_options = Arc::new(Mutex::new(advertised_config_options(
|
||||||
|
initial_options,
|
||||||
|
modes.as_ref(),
|
||||||
|
models.as_ref(),
|
||||||
|
)));
|
||||||
|
if let Some(modes) = &modes {
|
||||||
|
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
|
||||||
|
SessionId::new(session_id.as_str()),
|
||||||
|
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||||
|
modes.current_mode_id.clone(),
|
||||||
|
)),
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
if !config_options.lock().expect("config options").is_empty() {
|
||||||
|
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(config_notification(
|
||||||
|
&session_id,
|
||||||
|
config_options.lock().expect("config options").clone(),
|
||||||
|
))));
|
||||||
|
}
|
||||||
let _ = ready_tx.send(Ok(session_id.clone()));
|
let _ = ready_tx.send(Ok(session_id.clone()));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
cmd = commands_rx.recv() => match cmd {
|
cmd = commands_rx.recv() => match cmd {
|
||||||
Some(Command::Cancel) => {
|
Some(Command::Cancel) => {
|
||||||
|
permission_turn
|
||||||
|
.lock()
|
||||||
|
.expect("permission turn state")
|
||||||
|
.cancel_active();
|
||||||
let _ = cx.send_notification(CancelNotification::new(
|
let _ = cx.send_notification(CancelNotification::new(
|
||||||
SessionId::new(session_id.as_str()),
|
SessionId::new(session_id.as_str()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
Some(Command::Prompt { text, turn, done }) => {
|
||||||
|
permission_turn
|
||||||
|
.lock()
|
||||||
|
.expect("permission turn state")
|
||||||
|
.begin(turn);
|
||||||
|
let request = PromptRequest::new(
|
||||||
|
SessionId::new(session_id.as_str()),
|
||||||
|
vec![ContentBlock::from(text)],
|
||||||
|
);
|
||||||
|
let done = Arc::new(Mutex::new(Some(done)));
|
||||||
|
let callback_done = done.clone();
|
||||||
|
let callback_turn = permission_turn.clone();
|
||||||
|
let callback_updates = updates_tx.clone();
|
||||||
|
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
|
||||||
|
let callback_done = callback_done.clone();
|
||||||
|
let callback_turn = callback_turn.clone();
|
||||||
|
let callback_updates = callback_updates.clone();
|
||||||
|
async move {
|
||||||
|
callback_turn
|
||||||
|
.lock()
|
||||||
|
.expect("permission turn state")
|
||||||
|
.finish(turn);
|
||||||
|
// Route through the same channel as notifications so the
|
||||||
|
// event pump sees this after every chunk for the turn.
|
||||||
|
let result = result.map(|_| ()).map_err(|e| e.to_string());
|
||||||
|
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
|
||||||
|
if callback_updates.send(AcpUpdate::TurnDone { turn, result, done }).is_err() {
|
||||||
|
// Channel closed — nowhere left to signal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
permission_turn
|
||||||
|
.lock()
|
||||||
|
.expect("permission turn state")
|
||||||
|
.finish(turn);
|
||||||
|
if let Some(done) = done.lock().expect("prompt sender").take() {
|
||||||
|
let _ = done.send(Err(error.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Command::SetConfig { config_id, value, done }) => {
|
||||||
|
let done = Arc::new(Mutex::new(Some(done)));
|
||||||
|
let callback_done = done.clone();
|
||||||
|
let callback_options = config_options.clone();
|
||||||
|
let callback_updates = updates_tx.clone();
|
||||||
|
let callback_session = session_id.clone();
|
||||||
|
let callback_config_id = config_id.clone();
|
||||||
|
let callback_value = value.clone();
|
||||||
|
let sent = if config_id == LEGACY_MODEL_CONFIG_ID {
|
||||||
|
let Some(model_id) = value.as_value_id() else {
|
||||||
|
let _ = done.lock().expect("config sender").take()
|
||||||
|
.map(|done| done.send(Err("model must be a string value".into())));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
cx.send_request(LegacySetModelRequest {
|
||||||
|
session_id: SessionId::new(session_id.as_str()),
|
||||||
|
model_id: model_id.0.to_string(),
|
||||||
|
}).on_receiving_result(move |result| async move {
|
||||||
|
if result.is_ok() {
|
||||||
|
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||||
|
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
|
||||||
|
}
|
||||||
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
} else if config_id == LEGACY_MODE_CONFIG_ID {
|
||||||
|
let Some(mode_id) = value.as_value_id() else {
|
||||||
|
let _ = done.lock().expect("config sender").take()
|
||||||
|
.map(|done| done.send(Err("mode must be a string value".into())));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
cx.send_request(SetSessionModeRequest::new(
|
||||||
|
SessionId::new(session_id.as_str()),
|
||||||
|
mode_id.0.to_string(),
|
||||||
|
)).on_receiving_result(move |result| async move {
|
||||||
|
if result.is_ok() {
|
||||||
|
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||||
|
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
|
||||||
|
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
|
||||||
|
SessionId::new(callback_session.as_str()),
|
||||||
|
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||||
|
callback_value.as_value_id().expect("checked mode value").0.clone(),
|
||||||
|
)),
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
cx.send_request(SetSessionConfigOptionRequest::new(
|
||||||
|
SessionId::new(session_id.as_str()),
|
||||||
|
config_id,
|
||||||
|
value,
|
||||||
|
))
|
||||||
|
.on_receiving_result(move |result| async move {
|
||||||
|
if let Ok(response) = &result {
|
||||||
|
let mut current = callback_options.lock().expect("config options");
|
||||||
|
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
|
||||||
|
current.extend(response.config_options.clone());
|
||||||
|
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, current.clone()))));
|
||||||
|
}
|
||||||
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Err(error) = sent {
|
||||||
|
if let Some(done) = done.lock().expect("config sender").take() {
|
||||||
|
let _ = done.send(Err(error.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(Command::Close) | None => break,
|
Some(Command::Close) | None => break,
|
||||||
},
|
},
|
||||||
_ = cx.incoming_closed() => break,
|
_ = cx.incoming_closed() => break,
|
||||||
@@ -551,10 +1097,11 @@ async fn drain_stderr(
|
|||||||
stderr: tokio::process::ChildStderr,
|
stderr: tokio::process::ChildStderr,
|
||||||
pid: u32,
|
pid: u32,
|
||||||
tail: Arc<Mutex<VecDeque<u8>>>,
|
tail: Arc<Mutex<VecDeque<u8>>>,
|
||||||
|
adapter: AcpAdapter,
|
||||||
) {
|
) {
|
||||||
let mut lines = tokio::io::BufReader::new(stderr).lines();
|
let mut lines = tokio::io::BufReader::new(stderr).lines();
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
tracing::debug!(pid, "{ADAPTER_BIN}: {line}");
|
tracing::debug!(pid, provider = %adapter, "ACP adapter: {line}");
|
||||||
let mut tail = tail.lock().expect("stderr tail");
|
let mut tail = tail.lock().expect("stderr tail");
|
||||||
tail.extend(line.as_bytes());
|
tail.extend(line.as_bytes());
|
||||||
tail.push_back(b'\n');
|
tail.push_back(b'\n');
|
||||||
|
|||||||
@@ -19,16 +19,26 @@ Environment knobs, all optional:
|
|||||||
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
|
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
|
||||||
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
|
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
|
||||||
`session/new`
|
`session/new`
|
||||||
|
FAKE_ACP_SESSION_NEW_GATE=path wait for `path` to exist before answering
|
||||||
|
`session/new`
|
||||||
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
|
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
|
||||||
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
|
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
|
||||||
so the SIGTERM path is exercised)
|
so the SIGTERM path is exercised)
|
||||||
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
|
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
|
||||||
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
|
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
|
||||||
|
FAKE_ACP_CANCEL_SETTLES=1 complete the outstanding prompt on cancel
|
||||||
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
|
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
|
||||||
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
|
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
|
||||||
notifications ("replayed <i>") before answering,
|
notifications ("replayed <i>") before answering,
|
||||||
the way a real agent streams history back
|
the way a real agent streams history back
|
||||||
FAKE_ACP_LOAD_FAIL=1 answer `session/load` with a JSON-RPC error
|
FAKE_ACP_LOAD_FAIL=1 answer `session/load` with a JSON-RPC error
|
||||||
|
FAKE_ACP_DELAY_PROMPT_MS=n wait before answering `session/prompt`
|
||||||
|
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
|
||||||
|
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
|
||||||
|
JSON-RPC error
|
||||||
|
FAKE_ACP_SETTINGS=1 advertise legacy model/mode and modern effort options
|
||||||
|
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
|
||||||
|
callback until a later prompt
|
||||||
|
|
||||||
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
|
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
|
||||||
counting from 1) and logs `emit tick <n>`, so a test can make the agent
|
counting from 1) and logs `emit tick <n>`, so a test can make the agent
|
||||||
@@ -65,6 +75,45 @@ signal.signal(signal.SIGTERM, on_sigterm)
|
|||||||
|
|
||||||
session_id = f"fake-{os.getpid()}"
|
session_id = f"fake-{os.getpid()}"
|
||||||
ticks = 0
|
ticks = 0
|
||||||
|
prompt_failures = 0
|
||||||
|
pending_prompt_id = None
|
||||||
|
pending_permission_id = None
|
||||||
|
delayed_permission = False
|
||||||
|
current_model = "opus"
|
||||||
|
current_mode = "default"
|
||||||
|
current_effort = "high"
|
||||||
|
|
||||||
|
|
||||||
|
def settings_response(include_session=False):
|
||||||
|
result = {}
|
||||||
|
if include_session:
|
||||||
|
result["sessionId"] = session_id
|
||||||
|
if os.environ.get("FAKE_ACP_SETTINGS") == "1":
|
||||||
|
result.update({
|
||||||
|
"models": {
|
||||||
|
"currentModelId": current_model,
|
||||||
|
"availableModels": [
|
||||||
|
{"modelId": "opus", "name": "Opus"},
|
||||||
|
{"modelId": "sonnet", "name": "Sonnet"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"currentModeId": current_mode,
|
||||||
|
"availableModes": [
|
||||||
|
{"id": "default", "name": "Default"},
|
||||||
|
{"id": "plan", "name": "Plan"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"configOptions": [{
|
||||||
|
"id": "effort", "name": "Effort", "category": "thought_level",
|
||||||
|
"type": "select", "currentValue": current_effort,
|
||||||
|
"options": [
|
||||||
|
{"value": "low", "name": "Low"},
|
||||||
|
{"value": "high", "name": "High"},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def chunk(text):
|
def chunk(text):
|
||||||
@@ -97,6 +146,12 @@ for raw in sys.stdin:
|
|||||||
method = msg.get("method")
|
method = msg.get("method")
|
||||||
if method is None:
|
if method is None:
|
||||||
# A response to something we sent; nothing to do.
|
# A response to something we sent; nothing to do.
|
||||||
|
if msg.get("id") == pending_permission_id:
|
||||||
|
log("permission response " + json.dumps(msg.get("result"), sort_keys=True))
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
||||||
|
"result": {"stopReason": "end_turn"}})
|
||||||
|
pending_prompt_id = None
|
||||||
|
pending_permission_id = None
|
||||||
continue
|
continue
|
||||||
log(f"recv {method}")
|
log(f"recv {method}")
|
||||||
rid = msg.get("id")
|
rid = msg.get("id")
|
||||||
@@ -116,8 +171,11 @@ for raw in sys.stdin:
|
|||||||
"authMethods": [],
|
"authMethods": [],
|
||||||
}})
|
}})
|
||||||
elif method == "session/new":
|
elif method == "session/new":
|
||||||
|
gate = os.environ.get("FAKE_ACP_SESSION_NEW_GATE")
|
||||||
|
while gate and not os.path.exists(gate):
|
||||||
|
time.sleep(0.01)
|
||||||
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
|
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
|
||||||
send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}})
|
send({"jsonrpc": "2.0", "id": rid, "result": settings_response(True)})
|
||||||
send(chunk("hello from fake"))
|
send(chunk("hello from fake"))
|
||||||
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
|
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
@@ -130,13 +188,71 @@ for raw in sys.stdin:
|
|||||||
continue
|
continue
|
||||||
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
|
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
|
||||||
send(chunk(f"replayed {i + 1}"))
|
send(chunk(f"replayed {i + 1}"))
|
||||||
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
send({"jsonrpc": "2.0", "id": rid, "result": settings_response()})
|
||||||
send(chunk("hello after load"))
|
send(chunk("hello after load"))
|
||||||
|
elif method == "session/set_model":
|
||||||
|
current_model = params.get("modelId")
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||||
|
elif method == "session/set_mode":
|
||||||
|
current_mode = params.get("modeId")
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||||
|
elif method == "session/set_config_option":
|
||||||
|
current_effort = params.get("value")
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {
|
||||||
|
"configOptions": settings_response()["configOptions"]}})
|
||||||
elif method == "session/cancel":
|
elif method == "session/cancel":
|
||||||
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
|
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
|
||||||
send(chunk("final on cancel"))
|
send(chunk("final on cancel"))
|
||||||
|
if (os.environ.get("FAKE_ACP_CANCEL_SETTLES") == "1"
|
||||||
|
and pending_prompt_id is not None):
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
||||||
|
"result": {"stopReason": "cancelled"}})
|
||||||
|
pending_prompt_id = None
|
||||||
|
pending_permission_id = None
|
||||||
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
|
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
elif method == "session/prompt":
|
||||||
|
if os.environ.get("FAKE_ACP_PERMISSION_AFTER_CANCEL") == "1":
|
||||||
|
if pending_prompt_id is None:
|
||||||
|
pending_prompt_id = rid
|
||||||
|
delayed_permission = True
|
||||||
|
continue
|
||||||
|
if delayed_permission:
|
||||||
|
delayed_permission = False
|
||||||
|
pending_permission_id = "permission-1"
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
||||||
|
"method": "session/request_permission", "params": {
|
||||||
|
"sessionId": session_id,
|
||||||
|
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
||||||
|
"options": [
|
||||||
|
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
||||||
|
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
||||||
|
]
|
||||||
|
}})
|
||||||
|
continue
|
||||||
|
if os.environ.get("FAKE_ACP_REQUEST_PERMISSION") == "1":
|
||||||
|
pending_prompt_id = rid
|
||||||
|
pending_permission_id = "permission-1"
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
||||||
|
"method": "session/request_permission", "params": {
|
||||||
|
"sessionId": session_id,
|
||||||
|
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
||||||
|
"options": [
|
||||||
|
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
||||||
|
{"optionId": "session", "name": "Allow session", "kind": "allow_always"},
|
||||||
|
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
||||||
|
]
|
||||||
|
}})
|
||||||
|
continue
|
||||||
|
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_PROMPT_MS", "0")) / 1000)
|
||||||
|
fail_once = os.environ.get("FAKE_ACP_PROMPT_FAIL_ONCE") == "1"
|
||||||
|
if os.environ.get("FAKE_ACP_PROMPT_FAIL") == "1" or (fail_once and prompt_failures == 0):
|
||||||
|
prompt_failures += 1
|
||||||
|
send({"jsonrpc": "2.0", "id": rid,
|
||||||
|
"error": {"code": -32000, "message": "fake prompt failure"}})
|
||||||
|
else:
|
||||||
|
send({"jsonrpc": "2.0", "id": rid,
|
||||||
|
"result": {"stopReason": "end_turn"}})
|
||||||
elif rid is not None:
|
elif rid is not None:
|
||||||
send({"jsonrpc": "2.0", "id": rid,
|
send({"jsonrpc": "2.0", "id": rid,
|
||||||
"error": {"code": -32601, "message": f"method not found: {method}"}})
|
"error": {"code": -32601, "message": f"method not found: {method}"}})
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
|
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
|
||||||
//! one ignored test against the real `claude-code-acp`.
|
//! ignored smoke tests against each maintained adapter.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use maestro_acp::schema::{ContentBlock, SessionUpdate};
|
use maestro_acp::schema::{ContentBlock, SessionUpdate};
|
||||||
use maestro_acp::{AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
|
use maestro_acp::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn fake_agent() -> PathBuf {
|
fn fake_agent() -> PathBuf {
|
||||||
@@ -25,6 +25,7 @@ impl Fake {
|
|||||||
let bin = dir.path().join("bin");
|
let bin = dir.path().join("bin");
|
||||||
std::fs::create_dir_all(&bin).unwrap();
|
std::fs::create_dir_all(&bin).unwrap();
|
||||||
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
|
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
|
||||||
|
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
|
||||||
Self { dir }
|
Self { dir }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,12 +147,38 @@ async fn missing_adapter_names_the_install_command() {
|
|||||||
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
|
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
|
||||||
.await
|
.await
|
||||||
.expect_err("no adapter on PATH");
|
.expect_err("no adapter on PATH");
|
||||||
assert!(matches!(err, AcpError::AdapterMissing));
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
AcpError::AdapterMissing {
|
||||||
|
adapter: AcpAdapter::ClaudeCode
|
||||||
|
}
|
||||||
|
));
|
||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
assert!(msg.contains(ADAPTER_BIN), "{msg}");
|
assert!(msg.contains(ADAPTER_BIN), "{msg}");
|
||||||
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
|
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_adapter_has_its_own_binary_and_install_guidance() {
|
||||||
|
assert_eq!(AcpAdapter::Codex.binary(), "codex-acp");
|
||||||
|
assert!(AcpAdapter::Codex
|
||||||
|
.install_command()
|
||||||
|
.contains("@agentclientprotocol/codex-acp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_selects_the_codex_adapter() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
||||||
|
.await
|
||||||
|
.expect("spawn Codex adapter");
|
||||||
|
assert!(fake.log().contains("recv session/new"));
|
||||||
|
session
|
||||||
|
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn session_updates_reach_the_taken_receiver() {
|
async fn session_updates_reach_the_taken_receiver() {
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
@@ -166,6 +193,9 @@ async fn session_updates_reach_the_taken_receiver() {
|
|||||||
.await
|
.await
|
||||||
.expect("update within 5s")
|
.expect("update within 5s")
|
||||||
.expect("channel open");
|
.expect("channel open");
|
||||||
|
let maestro_acp::AcpUpdate::Notification(first) = first else {
|
||||||
|
panic!("expected Notification");
|
||||||
|
};
|
||||||
assert_eq!(first.session_id.0.as_ref(), session.session_id());
|
assert_eq!(first.session_id.0.as_ref(), session.session_id());
|
||||||
match first.update {
|
match first.update {
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
|
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
|
||||||
@@ -180,11 +210,71 @@ async fn session_updates_reach_the_taken_receiver() {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn advertised_settings_are_normalized_and_mutable() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut config = fake.config(cwd.path());
|
||||||
|
config.env.insert("FAKE_ACP_SETTINGS".into(), "1".into());
|
||||||
|
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||||
|
let mut updates = session.take_updates().expect("updates");
|
||||||
|
|
||||||
|
let initial = loop {
|
||||||
|
let update = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
||||||
|
.await
|
||||||
|
.expect("settings update")
|
||||||
|
.expect("channel open");
|
||||||
|
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let SessionUpdate::ConfigOptionUpdate(options) = n.update {
|
||||||
|
break options.config_options;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(initial.len(), 3);
|
||||||
|
assert!(initial
|
||||||
|
.iter()
|
||||||
|
.any(|option| option.id.0.as_ref() == "effort"));
|
||||||
|
assert!(initial
|
||||||
|
.iter()
|
||||||
|
.any(|option| option.id.0.as_ref() == "__maestro_legacy_model"));
|
||||||
|
assert!(initial
|
||||||
|
.iter()
|
||||||
|
.any(|option| option.id.0.as_ref() == "__maestro_legacy_mode"));
|
||||||
|
let model = initial
|
||||||
|
.iter()
|
||||||
|
.find(|option| option.id.0.as_ref() == "__maestro_legacy_model")
|
||||||
|
.expect("legacy model option");
|
||||||
|
let model = serde_json::to_value(model).unwrap();
|
||||||
|
assert_eq!(model["options"][0]["name"], "Opus");
|
||||||
|
assert!(model["options"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|option| option["value"] == "fable"));
|
||||||
|
|
||||||
|
let changed = session
|
||||||
|
.set_config(
|
||||||
|
"__maestro_legacy_model".into(),
|
||||||
|
maestro_acp::schema::SessionConfigOptionValue::value_id("sonnet"),
|
||||||
|
)
|
||||||
|
.expect("queue model change");
|
||||||
|
changed
|
||||||
|
.await
|
||||||
|
.expect("response delivered")
|
||||||
|
.expect("model changed");
|
||||||
|
fake.wait_for_log("recv session/set_model").await;
|
||||||
|
|
||||||
|
session
|
||||||
|
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn terminate_sends_cancel_then_sigterm() {
|
async fn terminate_sends_cancel_then_sigterm() {
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
let session = maestro_acp::spawn(fake.config(cwd.path()))
|
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
||||||
.await
|
.await
|
||||||
.expect("spawn");
|
.expect("spawn");
|
||||||
let pid = session.pid();
|
let pid = session.pid();
|
||||||
@@ -225,7 +315,7 @@ async fn terminate_is_satisfied_by_an_exit_on_cancel() {
|
|||||||
async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
|
async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
let mut config = fake.config(cwd.path());
|
let mut config = fake.config(cwd.path()).adapter(AcpAdapter::Codex);
|
||||||
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
|
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
|
||||||
|
|
||||||
let err = maestro_acp::spawn(config).await.expect_err("init fails");
|
let err = maestro_acp::spawn(config).await.expect_err("init fails");
|
||||||
@@ -284,7 +374,7 @@ async fn child_exit_before_handshake_reports_exited_early() {
|
|||||||
|
|
||||||
let err = maestro_acp::spawn(config).await.expect_err("child died");
|
let err = maestro_acp::spawn(config).await.expect_err("child died");
|
||||||
match &err {
|
match &err {
|
||||||
AcpError::ExitedEarly { status, stderr } => {
|
AcpError::ExitedEarly { status, stderr, .. } => {
|
||||||
assert!(status.contains('3'), "{status}");
|
assert!(status.contains('3'), "{status}");
|
||||||
assert!(stderr.contains("not logged in"), "{stderr}");
|
assert!(stderr.contains("not logged in"), "{stderr}");
|
||||||
}
|
}
|
||||||
@@ -306,7 +396,10 @@ async fn drop_terminates_and_reaps_the_child() {
|
|||||||
fake.wait_for_exit().await;
|
fake.wait_for_exit().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
|
fn chunk_text(update: &maestro_acp::AcpUpdate) -> String {
|
||||||
|
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
||||||
|
panic!("expected Notification, got {update:?}");
|
||||||
|
};
|
||||||
match &n.update {
|
match &n.update {
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||||
ContentBlock::Text(t) => t.text.clone(),
|
ContentBlock::Text(t) => t.text.clone(),
|
||||||
@@ -320,7 +413,10 @@ fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
|
|||||||
async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
|
async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
let mut config = fake.config(cwd.path()).load_session("fake-old");
|
let mut config = fake
|
||||||
|
.config(cwd.path())
|
||||||
|
.adapter(AcpAdapter::Codex)
|
||||||
|
.load_session("fake-old");
|
||||||
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
|
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
|
||||||
|
|
||||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||||
@@ -353,7 +449,10 @@ async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
|
|||||||
async fn load_session_requires_the_load_capability() {
|
async fn load_session_requires_the_load_capability() {
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
let mut config = fake.config(cwd.path()).load_session("fake-old");
|
let mut config = fake
|
||||||
|
.config(cwd.path())
|
||||||
|
.adapter(AcpAdapter::Codex)
|
||||||
|
.load_session("fake-old");
|
||||||
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
|
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
|
||||||
|
|
||||||
let err = maestro_acp::spawn(config)
|
let err = maestro_acp::spawn(config)
|
||||||
@@ -368,7 +467,10 @@ async fn load_session_requires_the_load_capability() {
|
|||||||
async fn load_session_error_fails_the_spawn() {
|
async fn load_session_error_fails_the_spawn() {
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
let mut config = fake.config(cwd.path()).load_session("fake-old");
|
let mut config = fake
|
||||||
|
.config(cwd.path())
|
||||||
|
.adapter(AcpAdapter::Codex)
|
||||||
|
.load_session("fake-old");
|
||||||
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
|
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
|
||||||
|
|
||||||
let err = maestro_acp::spawn(config).await.expect_err("load fails");
|
let err = maestro_acp::spawn(config).await.expect_err("load fails");
|
||||||
@@ -392,3 +494,21 @@ async fn real_adapter_handshake() {
|
|||||||
.await;
|
.await;
|
||||||
assert!(session.has_exited());
|
assert!(session.has_exited());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// End to end against the maintained Codex adapter. It needs a locally
|
||||||
|
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn real_codex_adapter_handshake() {
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let session =
|
||||||
|
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
|
||||||
|
.await
|
||||||
|
.expect("real codex-acp handshake");
|
||||||
|
assert!(!session.session_id().is_empty());
|
||||||
|
assert!(pid_alive(session.pid()));
|
||||||
|
session
|
||||||
|
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||||
|
.await;
|
||||||
|
assert!(session.has_exited());
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ use tokio_tungstenite::tungstenite::Message;
|
|||||||
pub enum AttachError {
|
pub enum AttachError {
|
||||||
#[error("ws: {0}")]
|
#[error("ws: {0}")]
|
||||||
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
||||||
|
#[error("daemon returned {status} ({code}): {message}")]
|
||||||
|
Rejected {
|
||||||
|
status: String,
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
#[error("termios: {0}")]
|
#[error("termios: {0}")]
|
||||||
@@ -32,6 +38,21 @@ pub enum AttachError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AttachError {
|
impl AttachError {
|
||||||
|
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
|
||||||
|
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
|
||||||
|
if let Some(body) = response.body().as_deref() {
|
||||||
|
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
|
||||||
|
return Self::Rejected {
|
||||||
|
status: response.status().to_string(),
|
||||||
|
code: error_body.error.code,
|
||||||
|
message: error_body.error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::WebSocket(error)
|
||||||
|
}
|
||||||
|
|
||||||
/// True when the failure was an inability to reach the WS endpoint at the
|
/// True when the failure was an inability to reach the WS endpoint at the
|
||||||
/// transport level (connection refused), as opposed to an HTTP-level
|
/// transport level (connection refused), as opposed to an HTTP-level
|
||||||
/// rejection from a daemon that did answer.
|
/// rejection from a daemon that did answer.
|
||||||
@@ -111,7 +132,9 @@ fn current_winsize() -> Option<(u16, u16)> {
|
|||||||
/// byte that exits cleanly without killing the session.
|
/// byte that exits cleanly without killing the session.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
||||||
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
let (ws, _resp) = tokio_tungstenite::connect_async(url)
|
||||||
|
.await
|
||||||
|
.map_err(AttachError::from_websocket)?;
|
||||||
let (mut sink, mut stream) = ws.split();
|
let (mut sink, mut stream) = ws.split();
|
||||||
|
|
||||||
let raw = RawTty::enable()?;
|
let raw = RawTty::enable()?;
|
||||||
@@ -233,4 +256,25 @@ mod tests {
|
|||||||
Err(AttachError::InvalidDetachKey(_))
|
Err(AttachError::InvalidDetachKey(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_surfaces_structured_handshake_errors() {
|
||||||
|
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
|
||||||
|
"not_terminal",
|
||||||
|
"session is not a terminal session",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let response = tokio_tungstenite::tungstenite::http::Response::builder()
|
||||||
|
.status(409)
|
||||||
|
.body(Some(body))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error =
|
||||||
|
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
/// Type text into a running session, as if it had been typed on an
|
/// Send text to a running session. Terminal sessions receive raw
|
||||||
/// attached terminal.
|
/// keystrokes; structured sessions receive an ACP prompt.
|
||||||
Send(SendArgs),
|
Send(SendArgs),
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
Hooks(HooksArgs),
|
Hooks(HooksArgs),
|
||||||
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
|
|||||||
/// there.
|
/// there.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub existing_branch: bool,
|
pub existing_branch: bool,
|
||||||
|
/// Run a Claude Code or Codex session in a plain terminal instead of the
|
||||||
|
/// structured (ACP) view it otherwise gets by default.
|
||||||
|
#[arg(long, conflicts_with = "structured")]
|
||||||
|
pub terminal: bool,
|
||||||
|
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
|
||||||
|
/// Codex commands have an adapter; the daemon refuses anything else.
|
||||||
|
#[arg(long)]
|
||||||
|
pub structured: bool,
|
||||||
/// After spawning, attach immediately.
|
/// After spawning, attach immediately.
|
||||||
#[arg(long, short = 'a')]
|
#[arg(long, short = 'a')]
|
||||||
pub attach: bool,
|
pub attach: bool,
|
||||||
@@ -125,7 +133,7 @@ pub struct KillArgs {
|
|||||||
/// Session ID to kill.
|
/// Session ID to kill.
|
||||||
pub session_id: Uuid,
|
pub session_id: Uuid,
|
||||||
/// End the session even when its worktree holds unsaved work (#81). The
|
/// End the session even when its worktree holds unsaved work (#81). The
|
||||||
/// worktree is left on disk — never removed.
|
/// worktree is removed along with the session.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use maestro_protocol::display::abbreviate_dir;
|
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||||
Session, SessionStatus, WorktreeSpec,
|
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -51,16 +51,33 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
};
|
};
|
||||||
|
|
||||||
let body = CreateSessionRequest {
|
let body = CreateSessionRequest {
|
||||||
view: Default::default(),
|
view: spawn_view(&args),
|
||||||
cwd: args.cwd.clone(),
|
cwd: args.cwd.clone(),
|
||||||
command: args.command.clone(),
|
command: args.command.clone(),
|
||||||
env,
|
env,
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
client.create_session(&body).await
|
client.create_session(&body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The view a `maestro spawn` asks for: structured whenever the command
|
||||||
|
/// resolves to an agent with an ACP adapter, since that is the richer session
|
||||||
|
/// and the one both other clients now default to. `--terminal` opts out;
|
||||||
|
/// `--structured` forces the request through even for a command this side
|
||||||
|
/// does not recognise, leaving the refusal to the daemon.
|
||||||
|
fn spawn_view(args: &SpawnArgs) -> SessionView {
|
||||||
|
if args.terminal {
|
||||||
|
return SessionView::Terminal;
|
||||||
|
}
|
||||||
|
if args.structured || supports_structured_view(&args.command) {
|
||||||
|
SessionView::Structured
|
||||||
|
} else {
|
||||||
|
SessionView::Terminal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||||
/// the work went.
|
/// the work went.
|
||||||
@@ -113,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
|
|||||||
/// `maestro send`. Answers with the session that was typed into, so the
|
/// `maestro send`. Answers with the session that was typed into, so the
|
||||||
/// caller can name it once the daemon has accepted the text.
|
/// caller can name it once the daemon has accepted the text.
|
||||||
///
|
///
|
||||||
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
||||||
/// against. Text comes from the arguments, or from stdin when there is none —
|
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
||||||
/// the shape a heredoc or a pipe wants.
|
/// there — ACP prompts are always complete messages).
|
||||||
|
///
|
||||||
|
/// Text comes from the arguments, or from stdin when there is none — the
|
||||||
|
/// shape a heredoc or a pipe wants.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_send(
|
pub async fn run_send(
|
||||||
client: &DaemonClient,
|
client: &DaemonClient,
|
||||||
@@ -138,19 +158,28 @@ pub async fn run_send(
|
|||||||
return Err(CliError::NothingToSend);
|
return Err(CliError::NothingToSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
// We always need the session list: even a bare UUID needs the view field
|
||||||
Ok(id) => id,
|
// to decide which endpoint to hit.
|
||||||
Err(_) => {
|
let sessions = client.list_sessions().await?;
|
||||||
let sessions = client.list_sessions().await?;
|
let id = resolve_session(&sessions, &args.session)?;
|
||||||
resolve_session(&sessions, &args.session)?
|
let view = sessions
|
||||||
}
|
.iter()
|
||||||
};
|
.find(|s| s.id == id)
|
||||||
|
.map(|s| s.view)
|
||||||
|
.unwrap_or(SessionView::Terminal);
|
||||||
|
|
||||||
let req = SendInputRequest {
|
match view {
|
||||||
text,
|
SessionView::Structured => {
|
||||||
submit: !args.no_enter,
|
client.send_structured_prompt(id, &text).await?;
|
||||||
};
|
}
|
||||||
client.send_input(id, &req).await?;
|
SessionView::Terminal => {
|
||||||
|
let req = SendInputRequest {
|
||||||
|
text,
|
||||||
|
submit: !args.no_enter,
|
||||||
|
};
|
||||||
|
client.send_input(id, &req).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,8 +208,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
}
|
}
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
|
||||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
for s in sorted {
|
for s in sorted {
|
||||||
@@ -190,11 +219,16 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
SessionStatus::Running => "running",
|
SessionStatus::Running => "running",
|
||||||
SessionStatus::Dead => "dead",
|
SessionStatus::Dead => "dead",
|
||||||
};
|
};
|
||||||
|
let view = match s.view {
|
||||||
|
SessionView::Terminal => "terminal",
|
||||||
|
SessionView::Structured => "structured",
|
||||||
|
};
|
||||||
let activity = match s.activity {
|
let activity = match s.activity {
|
||||||
ActivityState::Active => "active",
|
ActivityState::Active => "active",
|
||||||
ActivityState::Idle => "idle",
|
ActivityState::Idle => "idle",
|
||||||
ActivityState::AwaitingInput => "awaiting",
|
ActivityState::AwaitingInput => "awaiting",
|
||||||
ActivityState::Error => "error",
|
ActivityState::Error => "error",
|
||||||
|
ActivityState::Starting => "starting",
|
||||||
};
|
};
|
||||||
// Show the live foreground command alongside the spawn command when
|
// Show the live foreground command alongside the spawn command when
|
||||||
// the daemon has observed one that differs (NAP-340).
|
// the daemon has observed one that differs (NAP-340).
|
||||||
@@ -207,8 +241,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
let blocked = blocked_label(s);
|
let blocked = blocked_label(s);
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
|
||||||
s.id, status, activity, dir, command
|
s.id, status, view, activity, dir, command
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -230,11 +264,67 @@ mod tests {
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::{TimeZone, Utc};
|
use chrono::{TimeZone, Utc};
|
||||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::DaemonInfo;
|
use super::DaemonInfo;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
use crate::cli::{Cli, Command};
|
||||||
|
|
||||||
|
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
|
||||||
|
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
|
||||||
|
argv.extend_from_slice(extra);
|
||||||
|
let cli = Cli::try_parse_from(argv).expect("parses");
|
||||||
|
let Command::Spawn(args) = cli.command else {
|
||||||
|
panic!("expected spawn")
|
||||||
|
};
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CLI follows the two interactive clients: a command with an ACP
|
||||||
|
/// adapter spawns structured unless `--terminal` says otherwise, and
|
||||||
|
/// `--structured` still forces the request for a command this side does
|
||||||
|
/// not recognise.
|
||||||
|
#[test]
|
||||||
|
fn spawn_defaults_to_structured_for_supported_agents() {
|
||||||
|
use super::spawn_view;
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["claude"])),
|
||||||
|
SessionView::Structured
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["npx codex exec"])),
|
||||||
|
SessionView::Structured
|
||||||
|
);
|
||||||
|
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["claude", "--terminal"])),
|
||||||
|
SessionView::Terminal
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["opencode", "--structured"])),
|
||||||
|
SessionView::Structured
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_refuses_both_view_flags_at_once() {
|
||||||
|
let err = Cli::try_parse_from([
|
||||||
|
"maestro",
|
||||||
|
"spawn",
|
||||||
|
"--cwd",
|
||||||
|
"/tmp",
|
||||||
|
"--command",
|
||||||
|
"claude",
|
||||||
|
"--structured",
|
||||||
|
"--terminal",
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("--terminal"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||||
Session {
|
Session {
|
||||||
view: Default::default(),
|
view: Default::default(),
|
||||||
@@ -255,6 +345,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +378,22 @@ mod tests {
|
|||||||
assert!(s.contains("idle"), "{s}");
|
assert!(s.contains("idle"), "{s}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_exposes_terminal_and_structured_views() {
|
||||||
|
let terminal = session("/tmp", "bash", 1);
|
||||||
|
let mut structured = session("/tmp", "claude", 2);
|
||||||
|
structured.view = SessionView::Structured;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
|
||||||
|
let out = String::from_utf8(buf).unwrap();
|
||||||
|
let lines: Vec<&str> = out.lines().collect();
|
||||||
|
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
|
||||||
|
|
||||||
|
assert!(lines[header + 1].contains("terminal"), "{out}");
|
||||||
|
assert!(lines[header + 2].contains("structured"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
fn live_cwd_overrides_spawn_dir_in_table() {
|
||||||
let mut s = session("/home/naps/maestro", "bash", 1);
|
let mut s = session("/home/naps/maestro", "bash", 1);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||||
//! the unreachable-daemon path without compiling and forking a real daemon.
|
//! the unreachable-daemon path without compiling and forking a real daemon.
|
||||||
|
|
||||||
use maestro_protocol::{DaemonClient, DaemonInfo};
|
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
|
||||||
use maestro_testing::{session, MockDaemon};
|
use maestro_testing::{session, MockDaemon};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
@@ -97,6 +98,7 @@ async fn spawn_round_trips_through_the_daemon() {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -200,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
|
|||||||
assert_eq!(sent, id);
|
assert_eq!(sent, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A structured session routes through the ACP prompt endpoint, not the
|
||||||
|
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
|
||||||
|
/// complete messages).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_to_structured_session_uses_acp_prompt() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(13);
|
||||||
|
let mut s = session(id, "/tmp", "claude");
|
||||||
|
s.view = SessionView::Structured;
|
||||||
|
mock.add_session(s);
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: id.to_string(),
|
||||||
|
text: vec!["fix".into(), "the".into(), "tests".into()],
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert_eq!(sent, id);
|
||||||
|
// Terminal input must stay empty — the message went through ACP.
|
||||||
|
assert!(mock.inputs().is_empty());
|
||||||
|
let prompts = mock.structured_prompts();
|
||||||
|
assert_eq!(prompts.len(), 1);
|
||||||
|
assert_eq!(prompts[0].0, id);
|
||||||
|
assert_eq!(prompts[0].1.prompt, "fix the tests");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||||
let mock = MockDaemon::start().await;
|
let mock = MockDaemon::start().await;
|
||||||
|
|||||||
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "opencode".into(),
|
id: "opencode".into(),
|
||||||
label: "opencode".into(),
|
label: "opencode".into(),
|
||||||
command: "opencode".into(),
|
command: "opencode".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "zsh".into(),
|
id: "zsh".into(),
|
||||||
label: "Shell".into(),
|
label: "Shell".into(),
|
||||||
command: "zsh".into(),
|
command: "zsh".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
|
|||||||
id: CUSTOM_PRESET_ID.into(),
|
id: CUSTOM_PRESET_ID.into(),
|
||||||
label: "Custom…".into(),
|
label: "Custom…".into(),
|
||||||
command: String::new(),
|
command: String::new(),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +519,7 @@ auto_install = false
|
|||||||
id: "fish".into(),
|
id: "fish".into(),
|
||||||
label: "fish".into(),
|
label: "fish".into(),
|
||||||
command: "fish".into(),
|
command: "fish".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -551,6 +556,7 @@ auto_install = false
|
|||||||
id: " ".into(),
|
id: " ".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "x".into(),
|
command: "x".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -561,6 +567,7 @@ auto_install = false
|
|||||||
id: "x".into(),
|
id: "x".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "".into(),
|
command: "".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -578,11 +585,13 @@ auto_install = false
|
|||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a".into(),
|
label: "a".into(),
|
||||||
command: "a".into(),
|
command: "a".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a2".into(),
|
label: "a2".into(),
|
||||||
command: "a2".into(),
|
command: "a2".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -597,6 +606,7 @@ auto_install = false
|
|||||||
id: "custom".into(),
|
id: "custom".into(),
|
||||||
label: "Custom".into(),
|
label: "Custom".into(),
|
||||||
command: "whatever".into(),
|
command: "whatever".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,23 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a structured session's sidebar state from its ACP projection.
|
||||||
|
pub fn decide_structured(
|
||||||
|
turn_active: bool,
|
||||||
|
pending_approval: bool,
|
||||||
|
turn_failed: bool,
|
||||||
|
) -> ActivityState {
|
||||||
|
if turn_failed {
|
||||||
|
ActivityState::Error
|
||||||
|
} else if pending_approval {
|
||||||
|
ActivityState::AwaitingInput
|
||||||
|
} else if turn_active {
|
||||||
|
ActivityState::Active
|
||||||
|
} else {
|
||||||
|
ActivityState::Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How long a session's pane must be continuously focused (a client's pane,
|
/// How long a session's pane must be continuously focused (a client's pane,
|
||||||
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
||||||
/// operator has looked, so the rail stops asking for attention.
|
/// operator has looked, so the rail stops asking for attention.
|
||||||
@@ -136,6 +153,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_activity_follows_lifecycle_with_approval_precedence() {
|
||||||
|
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
|
||||||
|
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
|
||||||
|
assert_eq!(
|
||||||
|
decide_structured(true, true, false),
|
||||||
|
ActivityState::AwaitingInput
|
||||||
|
);
|
||||||
|
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn report_is_in_force_until_exactly_its_expiry() {
|
fn report_is_in_force_until_exactly_its_expiry() {
|
||||||
let ttl = Duration::from_secs(300);
|
let ttl = Duration::from_secs(300);
|
||||||
|
|||||||
@@ -80,13 +80,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_repo_dir_is_none() {
|
fn non_repo_dir_does_not_find_root_inside_fixture() {
|
||||||
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
|
// The fixture has no `.git`, so `find_root` must not return a path
|
||||||
// the walk can't accidentally hit a real repo above the test's cwd.
|
// inside it. On hosts where an ancestor of the temp dir *is* a repo
|
||||||
|
// (e.g. `/tmp` lives inside a checkout), the function legitimately
|
||||||
|
// returns that ancestor — the test only asserts the fixture itself
|
||||||
|
// was not mistaken for a repo.
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let sub = tmp.path().join("plain/dir");
|
let sub = tmp.path().join("plain/dir");
|
||||||
fs::create_dir_all(&sub).unwrap();
|
fs::create_dir_all(&sub).unwrap();
|
||||||
assert_eq!(find_root(&sub), None);
|
match find_root(&sub) {
|
||||||
|
None => {} // expected on most hosts
|
||||||
|
Some(root) => assert!(
|
||||||
|
!root.starts_with(tmp.path()),
|
||||||
|
"found .git inside the fixture at {root:?}, but none was created"
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub mod hooks;
|
|||||||
pub mod local_api;
|
pub mod local_api;
|
||||||
pub mod panes;
|
pub mod panes;
|
||||||
pub mod proc;
|
pub mod proc;
|
||||||
|
pub mod projection;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod release;
|
pub mod release;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{any, delete, get, post};
|
use axum::routing::{any, delete, get, post};
|
||||||
@@ -21,12 +22,14 @@ use maestro_config::SpawnPreset;
|
|||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
||||||
|
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::session::SessionManager;
|
use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
|
||||||
|
use crate::sidecar::EventCursor;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LocalApiState {
|
pub struct LocalApiState {
|
||||||
@@ -64,7 +67,20 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(routes::NAMES, get(names))
|
.route(routes::NAMES, get(names))
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::EVENTS, any(events_ws))
|
.route(routes::EVENTS, any(events_ws))
|
||||||
.route(routes::patterns::ATTACH, any(attach_ws));
|
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||||
|
.route(routes::patterns::STRUCTURED, any(structured_ws))
|
||||||
|
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
||||||
|
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||||
|
.route(
|
||||||
|
routes::patterns::STRUCTURED_ATTACHMENT,
|
||||||
|
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
|
||||||
|
)
|
||||||
|
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
||||||
|
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
|
||||||
|
.route(
|
||||||
|
routes::patterns::STRUCTURED_APPROVAL,
|
||||||
|
post(structured_approval),
|
||||||
|
);
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
{
|
{
|
||||||
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
|
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
|
||||||
@@ -164,12 +180,13 @@ fn dir_error_status(code: &str) -> StatusCode {
|
|||||||
fn session_error_status(code: &str) -> StatusCode {
|
fn session_error_status(code: &str) -> StatusCode {
|
||||||
match code {
|
match code {
|
||||||
"not_found" => StatusCode::NOT_FOUND,
|
"not_found" => StatusCode::NOT_FOUND,
|
||||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
|
||||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||||
"kill_guarded" => StatusCode::CONFLICT,
|
"kill_guarded" => StatusCode::CONFLICT,
|
||||||
// The session outlived its pane: nothing to type into, and nothing
|
// The session outlived its pane: nothing to type into, and nothing
|
||||||
// the daemon can do about it.
|
// the daemon can do about it.
|
||||||
"not_running" => StatusCode::CONFLICT,
|
"not_running" => StatusCode::CONFLICT,
|
||||||
|
"not_terminal" => StatusCode::CONFLICT,
|
||||||
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
_ => StatusCode::BAD_GATEWAY,
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
@@ -298,11 +315,10 @@ async fn rename_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
/// `POST /api/sessions/:id/activity` (#105): an agent reports a terminal
|
||||||
/// The report holds above the daemon's inference for a TTL, then inference
|
/// session's state. Structured sessions acknowledge and ignore these reports
|
||||||
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
|
/// because their activity comes from ACP. Unknown session is 404; an unknown
|
||||||
/// forever. Unknown session is 404; an unknown state value is rejected by the
|
/// state value is rejected by the JSON decoder (422). Answers `204 No Content`.
|
||||||
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
|
|
||||||
async fn report_activity(
|
async fn report_activity(
|
||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
@@ -369,6 +385,198 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
|||||||
(status, Json(ApiError::new(code, message))).into_response()
|
(status, Json(ApiError::new(code, message))).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ReplayQuery {
|
||||||
|
since: Option<u64>,
|
||||||
|
before: Option<u64>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_STRUCTURED_REPLAY_LIMIT: usize = 500;
|
||||||
|
|
||||||
|
async fn structured_replay(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(query): Query<ReplayQuery>,
|
||||||
|
) -> Response {
|
||||||
|
if query.since.is_some() && query.before.is_some() {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_cursor",
|
||||||
|
"use either since or before, not both",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let cursor = match query.before {
|
||||||
|
Some(before) => EventCursor::Before(Some(before)),
|
||||||
|
None => EventCursor::Since(query.since.unwrap_or(0)),
|
||||||
|
};
|
||||||
|
match s
|
||||||
|
.manager
|
||||||
|
.read_acp_projection_replay(
|
||||||
|
id,
|
||||||
|
cursor,
|
||||||
|
query
|
||||||
|
.limit
|
||||||
|
.unwrap_or(100)
|
||||||
|
.clamp(1, MAX_STRUCTURED_REPLAY_LIMIT),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(events)) => Json(events).into_response(),
|
||||||
|
// A terminal session, or a structured session whose adapter is no
|
||||||
|
// longer live, cannot provide a coherent replay continuation.
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AttachmentQuery {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_attachment(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(query): Query<AttachmentQuery>,
|
||||||
|
bytes: Bytes,
|
||||||
|
) -> Response {
|
||||||
|
let name = query.name;
|
||||||
|
if name.is_empty()
|
||||||
|
|| name.len() > 180
|
||||||
|
|| name == "."
|
||||||
|
|| name == ".."
|
||||||
|
|| name
|
||||||
|
.chars()
|
||||||
|
.any(|c| c.is_control() || c == '/' || c == '\\')
|
||||||
|
{
|
||||||
|
return error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_filename",
|
||||||
|
"use a filename without directory separators, up to 180 bytes",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match s.manager.store_acp_attachment(id, &name, &bytes).await {
|
||||||
|
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_prompt(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<StructuredPromptRequest>,
|
||||||
|
) -> Response {
|
||||||
|
match s.manager.queue_acp_prompt(id, body.prompt, None).await {
|
||||||
|
Ok(QueueAcpPrompt::Dispatched) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(QueueAcpPrompt::Queued) => StatusCode::ACCEPTED.into_response(),
|
||||||
|
Ok(QueueAcpPrompt::Full) => error_response(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"queue_full",
|
||||||
|
"the prompt queue has reached its capacity",
|
||||||
|
),
|
||||||
|
Ok(QueueAcpPrompt::Unavailable) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Ok(QueueAcpPrompt::Failed(message)) => {
|
||||||
|
error_response(StatusCode::BAD_GATEWAY, "acp_prompt_failed", &message)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||||
|
match s.manager.cancel_acp_turn(id).await {
|
||||||
|
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(false) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_config(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<StructuredConfigRequest>,
|
||||||
|
) -> Response {
|
||||||
|
match s
|
||||||
|
.manager
|
||||||
|
.set_acp_config(id, body.config_id, body.value)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(SetAcpConfig::Updated) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(SetAcpConfig::InvalidValue) => error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_config_value",
|
||||||
|
"configuration values must be strings or booleans",
|
||||||
|
),
|
||||||
|
Ok(SetAcpConfig::Unavailable) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Ok(SetAcpConfig::Failed(message)) => {
|
||||||
|
error_response(StatusCode::BAD_GATEWAY, "acp_config_failed", &message)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_approval(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path((id, nonce)): Path<(Uuid, String)>,
|
||||||
|
Json(decision): Json<StructuredApprovalDecision>,
|
||||||
|
) -> Response {
|
||||||
|
let decision = match decision {
|
||||||
|
StructuredApprovalDecision::AllowOnce => crate::session::ApprovalDecision::AllowOnce,
|
||||||
|
StructuredApprovalDecision::AllowForSession => {
|
||||||
|
crate::session::ApprovalDecision::AllowForSession
|
||||||
|
}
|
||||||
|
StructuredApprovalDecision::Deny => crate::session::ApprovalDecision::Deny,
|
||||||
|
};
|
||||||
|
match s.manager.resolve_acp_approval(id, &nonce, decision).await {
|
||||||
|
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
||||||
/// scanner starts with its cache cold; wire
|
/// scanner starts with its cache cold; wire
|
||||||
@@ -406,12 +614,23 @@ async fn attach_ws(
|
|||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
ws: axum::extract::WebSocketUpgrade,
|
ws: axum::extract::WebSocketUpgrade,
|
||||||
) -> Result<Response, Response> {
|
) -> Result<Response, Response> {
|
||||||
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
|
||||||
return Err(error_response(
|
Ok(subscription) => subscription,
|
||||||
StatusCode::NOT_FOUND,
|
Err(SessionError::NotFound(_)) => {
|
||||||
"unknown_session",
|
return Err(error_response(
|
||||||
"no such session on this daemon",
|
StatusCode::NOT_FOUND,
|
||||||
));
|
"unknown_session",
|
||||||
|
"no such session on this daemon",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let info = error.to_error_info();
|
||||||
|
return Err(error_response(
|
||||||
|
session_error_status(&info.code),
|
||||||
|
&info.code,
|
||||||
|
&info.message,
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let manager = s.manager.clone();
|
let manager = s.manager.clone();
|
||||||
Ok(ws.on_upgrade(move |socket| async move {
|
Ok(ws.on_upgrade(move |socket| async move {
|
||||||
@@ -419,6 +638,75 @@ async fn attach_ws(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
async fn structured_ws(
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
ws: axum::extract::WebSocketUpgrade,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
let Some((snapshot, state, rx)) = s
|
||||||
|
.manager
|
||||||
|
.subscribe_acp_projection(id, EventCursor::Since(0))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
})?
|
||||||
|
else {
|
||||||
|
return Err(error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(ws.on_upgrade(move |socket| async move {
|
||||||
|
let _ = pump_structured(socket, snapshot, state, rx).await;
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pump_structured(
|
||||||
|
socket: axum::extract::ws::WebSocket,
|
||||||
|
snapshot: maestro_protocol::TranscriptSnapshot,
|
||||||
|
state: maestro_protocol::ReducedState,
|
||||||
|
mut rx: tokio::sync::broadcast::Receiver<crate::projection::ProjectionUpdate>,
|
||||||
|
) -> Result<(), axum::Error> {
|
||||||
|
use axum::extract::ws::Message;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
|
||||||
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
for event in [
|
||||||
|
StructuredEvent::TranscriptSnapshot { snapshot },
|
||||||
|
StructuredEvent::ReducedState { state },
|
||||||
|
] {
|
||||||
|
if sink
|
||||||
|
.send(Message::Text(
|
||||||
|
serde_json::to_string(&event).expect("structured event encode"),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
update = rx.recv() => match update {
|
||||||
|
Ok(update) => {
|
||||||
|
let event = StructuredEvent::ProjectionUpdate { transcript: update.transcript, state: update.state };
|
||||||
|
if sink.send(Message::Text(serde_json::to_string(&event).expect("structured event encode"))).await.is_err() { break; }
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
},
|
||||||
|
frame = stream.next() => match frame {
|
||||||
|
None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break,
|
||||||
|
Some(Ok(_)) => {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
|
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+2177
-253
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@
|
|||||||
//! advertised but with empty metadata (handled by the caller, not here).
|
//! advertised but with empty metadata (handled by the caller, not here).
|
||||||
//!
|
//!
|
||||||
//! A structured session (#135) also owns `<id>.events.jsonl` next to its
|
//! A structured session (#135) also owns `<id>.events.jsonl` next to its
|
||||||
//! sidecar: every `session/update` notification its adapter sent, one JSON
|
//! sidecar: every `session/update` notification and daemon approval row is one
|
||||||
//! object per line, appended before it is broadcast. The log is the source of
|
//! JSON object, appended before it is broadcast. The log is the source of
|
||||||
//! truth for the transcript; `seq` is the line's 1-based position, so a
|
//! truth for the transcript; `seq` is the line's 1-based position, so a
|
||||||
//! client that holds seq `n` asks for [`EventCursor::Since`]`(n)`. A line
|
//! client that holds seq `n` asks for [`EventCursor::Since`]`(n)`. A line
|
||||||
//! that does not parse — including the partial line a hard kill leaves at
|
//! that does not parse — including the partial line a hard kill leaves at
|
||||||
@@ -21,7 +21,7 @@ use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use maestro_acp::schema::SessionNotification;
|
use maestro_acp::schema::SessionNotification;
|
||||||
use maestro_protocol::Session;
|
use maestro_protocol::{Session, TranscriptRow};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -56,6 +56,28 @@ impl SidecarStore {
|
|||||||
self.dir.join(format!("{id}.events.jsonl"))
|
self.dir.join(format!("{id}.events.jsonl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Durable evidence that graceful daemon shutdown ended this structured
|
||||||
|
/// session and the next daemon may resume it.
|
||||||
|
pub fn recovery_path_for(&self, id: Uuid) -> PathBuf {
|
||||||
|
self.dir.join(format!("{id}.recover"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark `id` as eligible for one structured-session recovery attempt.
|
||||||
|
pub fn mark_for_recovery(&self, id: Uuid) -> Result<(), SidecarError> {
|
||||||
|
std::fs::create_dir_all(&self.dir)?;
|
||||||
|
std::fs::write(self.recovery_path_for(id), [])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume the graceful-shutdown recovery marker for `id`.
|
||||||
|
pub fn take_recovery_marker(&self, id: Uuid) -> Result<bool, SidecarError> {
|
||||||
|
match std::fs::remove_file(self.recovery_path_for(id)) {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the event log for `id` for appending, creating it (and the
|
/// Open the event log for `id` for appending, creating it (and the
|
||||||
/// directory) when missing. One handle per live session: the caller
|
/// directory) when missing. One handle per live session: the caller
|
||||||
/// keeps it for the session's lifetime rather than reopening per
|
/// keeps it for the session's lifetime rather than reopening per
|
||||||
@@ -90,7 +112,7 @@ impl SidecarStore {
|
|||||||
}
|
}
|
||||||
file.seek(SeekFrom::End(0))?;
|
file.seek(SeekFrom::End(0))?;
|
||||||
Ok(EventLog {
|
Ok(EventLog {
|
||||||
file,
|
file: Box::new(file),
|
||||||
last_seq: lines,
|
last_seq: lines,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -128,10 +150,10 @@ impl SidecarStore {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Ok(notification) = serde_json::from_slice::<SessionNotification>(&line) else {
|
let Ok(event) = PersistedEvent::decode(&line) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
out.push_back(LoggedEvent { seq, notification });
|
out.push_back(LoggedEvent { seq, event });
|
||||||
match cursor {
|
match cursor {
|
||||||
EventCursor::Since(_) if out.len() >= limit => break,
|
EventCursor::Since(_) if out.len() >= limit => break,
|
||||||
EventCursor::Before(_) if out.len() > limit => {
|
EventCursor::Before(_) if out.len() > limit => {
|
||||||
@@ -164,10 +186,12 @@ impl SidecarStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the sidecar and the event log for `id`. No-op for whichever
|
/// Delete the sidecar, event log, and recovery marker for `id`. No-op for
|
||||||
/// does not exist.
|
/// whichever does not exist.
|
||||||
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
|
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
|
||||||
remove_if_present(&self.events_path_for(id))?;
|
remove_if_present(&self.events_path_for(id))?;
|
||||||
|
remove_if_present(&self.dir.join(format!("{id}.approvals.jsonl")))?;
|
||||||
|
remove_if_present(&self.recovery_path_for(id))?;
|
||||||
remove_if_present(&self.path_for(id))
|
remove_if_present(&self.path_for(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,18 +252,132 @@ pub enum EventCursor {
|
|||||||
pub struct LoggedEvent {
|
pub struct LoggedEvent {
|
||||||
/// 1-based line position in the log; the cursor for [`EventCursor`].
|
/// 1-based line position in the log; the cursor for [`EventCursor`].
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
pub notification: SessionNotification,
|
pub event: PersistedEvent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single entry in the structured event history.
|
||||||
|
///
|
||||||
|
/// ACP notifications retain their original JSON representation, so existing
|
||||||
|
/// event logs remain readable. Daemon-generated rows use an envelope so they
|
||||||
|
/// can share the same cursor sequence without pretending to be ACP traffic.
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(tag = "maestroEvent", rename_all = "snake_case")]
|
||||||
|
pub enum PersistedEvent {
|
||||||
|
User {
|
||||||
|
row: TranscriptRow,
|
||||||
|
},
|
||||||
|
Approval {
|
||||||
|
row: TranscriptRow,
|
||||||
|
},
|
||||||
|
Turn {
|
||||||
|
row: TranscriptRow,
|
||||||
|
},
|
||||||
|
#[serde(skip)]
|
||||||
|
Notification(Box<SessionNotification>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersistedEvent {
|
||||||
|
fn decode(line: &[u8]) -> Result<Self, serde_json::Error> {
|
||||||
|
serde_json::from_slice::<SessionNotification>(line)
|
||||||
|
.map(|notification| Self::Notification(Box::new(notification)))
|
||||||
|
.or_else(|_| serde_json::from_slice(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SessionNotification> for PersistedEvent {
|
||||||
|
fn from(notification: SessionNotification) -> Self {
|
||||||
|
Self::Notification(Box::new(notification))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&SessionNotification> for PersistedEvent {
|
||||||
|
fn from(notification: &SessionNotification) -> Self {
|
||||||
|
Self::Notification(Box::new(notification.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&PersistedEvent> for PersistedEvent {
|
||||||
|
fn from(event: &PersistedEvent) -> Self {
|
||||||
|
event.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an [`EventLog`] appends onto: something seekable by length and
|
||||||
|
/// truncatable back to one, so a failed append can be rolled off the end
|
||||||
|
/// again. A real [`File`] in production; an in-memory fault double in tests
|
||||||
|
/// (#184) — the same [`EventLog::append`] rollback logic runs over both, so
|
||||||
|
/// the behavior under test is the production behavior, not a stand-in for
|
||||||
|
/// it.
|
||||||
|
pub(crate) trait LogSink: Write + Send {
|
||||||
|
fn len(&mut self) -> std::io::Result<u64>;
|
||||||
|
fn truncate(&mut self, len: u64) -> std::io::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogSink for File {
|
||||||
|
fn len(&mut self) -> std::io::Result<u64> {
|
||||||
|
Ok(self.metadata()?.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
|
||||||
|
self.set_len(len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a failed [`EventLog::append`].
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppendError {
|
||||||
|
/// The write or flush failed, but the log was rolled back to exactly the
|
||||||
|
/// byte it was at before the call: `last_seq` and the file agree, and
|
||||||
|
/// this same [`EventLog`] is safe to keep appending to. The event itself
|
||||||
|
/// was never persisted or broadcast.
|
||||||
|
#[error("event append failed, rolled back cleanly: {0}")]
|
||||||
|
Failed(#[source] SidecarError),
|
||||||
|
/// The append failed *and* the rollback did not restore the log to its
|
||||||
|
/// prior length, so the file's line count and `last_seq` may now
|
||||||
|
/// disagree. The caller must not append to this [`EventLog`] again —
|
||||||
|
/// treat the session as dead rather than promise consistency it cannot
|
||||||
|
/// keep.
|
||||||
|
#[error("event append failed ({cause}) and rollback did not restore consistency ({rollback})")]
|
||||||
|
Fatal {
|
||||||
|
cause: SidecarError,
|
||||||
|
rollback: SidecarError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SidecarError> for AppendError {
|
||||||
|
fn from(error: SidecarError) -> Self {
|
||||||
|
AppendError::Failed(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An open, append-only handle on one session's event log. Obtained from
|
/// An open, append-only handle on one session's event log. Obtained from
|
||||||
/// [`SidecarStore::open_events`]; held for the session's lifetime.
|
/// [`SidecarStore::open_events`]; held for the session's lifetime.
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct EventLog {
|
pub struct EventLog {
|
||||||
file: File,
|
file: Box<dyn LogSink>,
|
||||||
last_seq: u64,
|
last_seq: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for EventLog {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("EventLog")
|
||||||
|
.field("last_seq", &self.last_seq)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl EventLog {
|
impl EventLog {
|
||||||
|
/// Wrap an arbitrary sink as an [`EventLog`], for tests that need to
|
||||||
|
/// inject a deterministic append failure (#184) — a partial write, a
|
||||||
|
/// flush that fails after the bytes are already down, or a rollback that
|
||||||
|
/// cannot itself complete.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn for_test(file: impl LogSink + 'static, last_seq: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
file: Box::new(file),
|
||||||
|
last_seq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The seq of the last line, `0` for an empty log.
|
/// The seq of the last line, `0` for an empty log.
|
||||||
pub fn last_seq(&self) -> u64 {
|
pub fn last_seq(&self) -> u64 {
|
||||||
self.last_seq
|
self.last_seq
|
||||||
@@ -249,12 +387,262 @@ impl EventLog {
|
|||||||
/// handed to the kernel in a single write before this returns, so a
|
/// handed to the kernel in a single write before this returns, so a
|
||||||
/// caller that broadcasts afterwards never announces an event the log
|
/// caller that broadcasts afterwards never announces an event the log
|
||||||
/// would not replay — short of the machine losing power.
|
/// would not replay — short of the machine losing power.
|
||||||
pub fn append(&mut self, notification: &SessionNotification) -> Result<u64, SidecarError> {
|
///
|
||||||
let mut line = serde_json::to_vec(notification)?;
|
/// A `write_all` that errors partway can leave an unterminated prefix on
|
||||||
|
/// disk; a `flush` that errors after a *complete* `write_all` leaves
|
||||||
|
/// ambiguity about whether that full, well-formed line should count —
|
||||||
|
/// counting it would silently shift every later seq by one, since
|
||||||
|
/// `last_seq` was never bumped for it. Both cases are rolled back to the
|
||||||
|
/// exact pre-append length before returning, so `last_seq` and the file
|
||||||
|
/// never disagree and the same [`EventLog`] can be appended to again
|
||||||
|
/// immediately. If the rollback itself fails, the log's consistency can
|
||||||
|
/// no longer be promised and [`AppendError::Fatal`] is returned instead.
|
||||||
|
pub fn append(&mut self, event: impl Into<PersistedEvent>) -> Result<u64, AppendError> {
|
||||||
|
let event = event.into();
|
||||||
|
let mut line = match &event {
|
||||||
|
PersistedEvent::Notification(notification) => serde_json::to_vec(notification)
|
||||||
|
.map_err(|e| AppendError::from(SidecarError::from(e)))?,
|
||||||
|
_ => {
|
||||||
|
serde_json::to_vec(&event).map_err(|e| AppendError::from(SidecarError::from(e)))?
|
||||||
|
}
|
||||||
|
};
|
||||||
line.push(b'\n');
|
line.push(b'\n');
|
||||||
self.file.write_all(&line)?;
|
|
||||||
self.file.flush()?;
|
let pre_len = self
|
||||||
|
.file
|
||||||
|
.len()
|
||||||
|
.map_err(|e| AppendError::from(SidecarError::from(e)))?;
|
||||||
|
|
||||||
|
if let Err(cause) = self.file.write_all(&line) {
|
||||||
|
return Err(self.rollback_after(pre_len, cause));
|
||||||
|
}
|
||||||
|
if let Err(cause) = self.file.flush() {
|
||||||
|
return Err(self.rollback_after(pre_len, cause));
|
||||||
|
}
|
||||||
|
|
||||||
self.last_seq += 1;
|
self.last_seq += 1;
|
||||||
Ok(self.last_seq)
|
Ok(self.last_seq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rollback_after(&mut self, pre_len: u64, cause: std::io::Error) -> AppendError {
|
||||||
|
match self.file.truncate(pre_len) {
|
||||||
|
Ok(()) => AppendError::Failed(SidecarError::Io(cause)),
|
||||||
|
Err(rollback) => AppendError::Fatal {
|
||||||
|
cause: SidecarError::Io(cause),
|
||||||
|
rollback: SidecarError::Io(rollback),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes shared between a [`FaultyLogSink`] moved into an [`EventLog`] and
|
||||||
|
/// the test that planted it, so the test can inspect what actually ended up
|
||||||
|
/// on the "disk" after a fault without reaching into `EventLog`'s private
|
||||||
|
/// `file` field.
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub(crate) struct SharedBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl SharedBuf {
|
||||||
|
pub(crate) fn snapshot(&self) -> Vec<u8> {
|
||||||
|
self.0.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An in-memory [`LogSink`] double that injects one deterministic fault — a
|
||||||
|
/// short write, a flush that fails after a full write, or a rollback that
|
||||||
|
/// cannot itself complete — then behaves like a healthy sink from then on.
|
||||||
|
/// Lets a test prove the *same* [`EventLog`] recovers, rather than one with a
|
||||||
|
/// handle swapped in from a healthy disk, which production never does
|
||||||
|
/// (#184).
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct FaultyLogSink {
|
||||||
|
pub(crate) buf: SharedBuf,
|
||||||
|
/// Accept this many bytes on the next `write` call, then fail the
|
||||||
|
/// *following* call — a short write followed by an error, exactly what
|
||||||
|
/// `write_all` sees from a real fd that dies mid-line. `Write::write`
|
||||||
|
/// must not report an error on a call that made partial progress, so
|
||||||
|
/// the fault is delivered one call later, via `pending_write_error`.
|
||||||
|
pub(crate) fail_write_after: Option<usize>,
|
||||||
|
pub(crate) pending_write_error: bool,
|
||||||
|
pub(crate) fail_flush_once: bool,
|
||||||
|
pub(crate) fail_truncate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl Write for FaultyLogSink {
|
||||||
|
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||||
|
if self.pending_write_error {
|
||||||
|
self.pending_write_error = false;
|
||||||
|
return Err(std::io::Error::other("fault: write failed mid-line"));
|
||||||
|
}
|
||||||
|
if let Some(allowed) = self.fail_write_after.take() {
|
||||||
|
let allowed = allowed.min(data.len());
|
||||||
|
if allowed == 0 {
|
||||||
|
// No progress was made, so it's fine to report the error
|
||||||
|
// from this same call.
|
||||||
|
return Err(std::io::Error::other("fault: write failed mid-line"));
|
||||||
|
}
|
||||||
|
self.buf
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.extend_from_slice(&data[..allowed]);
|
||||||
|
self.pending_write_error = true;
|
||||||
|
return Ok(allowed);
|
||||||
|
}
|
||||||
|
self.buf.0.lock().unwrap().extend_from_slice(data);
|
||||||
|
Ok(data.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
if self.fail_flush_once {
|
||||||
|
self.fail_flush_once = false;
|
||||||
|
return Err(std::io::Error::other(
|
||||||
|
"fault: flush failed after a full write",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl LogSink for FaultyLogSink {
|
||||||
|
fn len(&mut self) -> std::io::Result<u64> {
|
||||||
|
Ok(self.buf.0.lock().unwrap().len() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
|
||||||
|
if self.fail_truncate {
|
||||||
|
return Err(std::io::Error::other("fault: rollback truncate failed"));
|
||||||
|
}
|
||||||
|
self.buf.0.lock().unwrap().truncate(len as usize);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_event(id: &str) -> PersistedEvent {
|
||||||
|
PersistedEvent::Approval {
|
||||||
|
row: TranscriptRow {
|
||||||
|
id: id.to_owned(),
|
||||||
|
kind: maestro_protocol::TranscriptRowKind::Approval,
|
||||||
|
data: serde_json::json!({"tool": "bash"}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replays `bytes` as a real on-disk log via the production read path,
|
||||||
|
/// so "live and durable agree" is checked with the same code a
|
||||||
|
/// reconnecting client would hit, not a hand-rolled line splitter.
|
||||||
|
fn replay(bytes: &[u8]) -> Vec<LoggedEvent> {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = SidecarStore::new(dir.path());
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
std::fs::create_dir_all(store.dir()).unwrap();
|
||||||
|
std::fs::write(store.events_path_for(id), bytes).unwrap();
|
||||||
|
store.read_events(id, EventCursor::Since(0), 100).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_rolls_back_a_partial_write_and_the_same_instance_recovers() {
|
||||||
|
let buf = SharedBuf::default();
|
||||||
|
let writer = FaultyLogSink {
|
||||||
|
buf: buf.clone(),
|
||||||
|
fail_write_after: Some(5),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut log = EventLog::for_test(writer, 0);
|
||||||
|
|
||||||
|
let err = log.append(sample_event("r1")).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, AppendError::Failed(_)),
|
||||||
|
"a repairable rollback must not be reported fatal"
|
||||||
|
);
|
||||||
|
assert_eq!(log.last_seq(), 0, "cursor must stay put on a failed append");
|
||||||
|
assert!(
|
||||||
|
buf.snapshot().is_empty(),
|
||||||
|
"the partial prefix a failed write_all leaves behind must be truncated away"
|
||||||
|
);
|
||||||
|
|
||||||
|
let seq = log
|
||||||
|
.append(sample_event("r1"))
|
||||||
|
.expect("the same instance must be able to append again after a clean rollback");
|
||||||
|
assert_eq!(seq, 1);
|
||||||
|
assert_eq!(log.last_seq(), 1);
|
||||||
|
|
||||||
|
let bytes = buf.snapshot();
|
||||||
|
assert_eq!(
|
||||||
|
bytes.iter().filter(|&&b| b == b'\n').count(),
|
||||||
|
1,
|
||||||
|
"exactly one well-formed line, not a partial prefix glued to the recovered append"
|
||||||
|
);
|
||||||
|
|
||||||
|
let replayed = replay(&bytes);
|
||||||
|
assert_eq!(replayed.len(), 1);
|
||||||
|
assert_eq!(replayed[0].seq, log.last_seq());
|
||||||
|
let PersistedEvent::Approval { row } = &replayed[0].event else {
|
||||||
|
panic!("approval event");
|
||||||
|
};
|
||||||
|
assert_eq!(row.id, "r1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_rolls_back_when_flush_fails_after_a_full_write() {
|
||||||
|
let buf = SharedBuf::default();
|
||||||
|
let writer = FaultyLogSink {
|
||||||
|
buf: buf.clone(),
|
||||||
|
fail_flush_once: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut log = EventLog::for_test(writer, 0);
|
||||||
|
|
||||||
|
let err = log.append(sample_event("r1")).unwrap_err();
|
||||||
|
assert!(matches!(err, AppendError::Failed(_)));
|
||||||
|
assert_eq!(log.last_seq(), 0);
|
||||||
|
assert!(
|
||||||
|
buf.snapshot().is_empty(),
|
||||||
|
"a full-but-unflushed line must not survive uncounted by last_seq — it would \
|
||||||
|
silently shift every later seq by one"
|
||||||
|
);
|
||||||
|
|
||||||
|
let seq = log
|
||||||
|
.append(sample_event("r1"))
|
||||||
|
.expect("the same instance must recover once flush stops failing");
|
||||||
|
assert_eq!(seq, 1);
|
||||||
|
|
||||||
|
let bytes = buf.snapshot();
|
||||||
|
let replayed = replay(&bytes);
|
||||||
|
assert_eq!(replayed.len(), 1);
|
||||||
|
assert_eq!(replayed[0].seq, log.last_seq());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_reports_fatal_when_rollback_cannot_restore_consistency() {
|
||||||
|
let buf = SharedBuf::default();
|
||||||
|
let writer = FaultyLogSink {
|
||||||
|
buf,
|
||||||
|
fail_write_after: Some(3),
|
||||||
|
fail_truncate: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut log = EventLog::for_test(writer, 0);
|
||||||
|
|
||||||
|
let err = log.append(sample_event("r1")).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, AppendError::Fatal { .. }),
|
||||||
|
"an unrepairable rollback must be surfaced as fatal, never silently treated as \
|
||||||
|
a clean recoverable failure"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log.last_seq(),
|
||||||
|
0,
|
||||||
|
"a fatal append must not advance the cursor either"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,7 @@ async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ use common::TmuxFixture;
|
|||||||
use maestro_daemon::sidecar::SidecarStore;
|
use maestro_daemon::sidecar::SidecarStore;
|
||||||
use maestro_daemon::worktree::WorktreeManager;
|
use maestro_daemon::worktree::WorktreeManager;
|
||||||
use maestro_daemon::SessionManager;
|
use maestro_daemon::SessionManager;
|
||||||
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
|
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
sidecar: SidecarStore,
|
sidecar: SidecarStore,
|
||||||
_tmux: TmuxFixture,
|
tmux: TmuxFixture,
|
||||||
_state_dir: TempDir,
|
_state_dir: TempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
|
|||||||
Fixture {
|
Fixture {
|
||||||
manager,
|
manager,
|
||||||
sidecar,
|
sidecar,
|
||||||
_tmux: tmux,
|
tmux,
|
||||||
_state_dir: state_dir,
|
_state_dir: state_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +56,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -170,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
|
|||||||
let live = fx.sidecar.read(id).unwrap().unwrap();
|
let live = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
assert_eq!(display::name_source(&live), NameSource::Generated);
|
assert_eq!(display::name_source(&live), NameSource::Generated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A session that dies naturally (liveness watcher marks it dead) must not
|
||||||
|
/// overwrite a haiku rename that landed before the death. Regression test for
|
||||||
|
/// the stale-clone sidecar write in `mark_session_dead`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn session_death_preserves_the_haiku_name() {
|
||||||
|
let fx = fixture();
|
||||||
|
let id = spawn(&fx.manager, None).await;
|
||||||
|
|
||||||
|
fx.manager.claim_autoname(id).await.unwrap();
|
||||||
|
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
|
||||||
|
|
||||||
|
// Verify the sidecar has Haiku before the session dies.
|
||||||
|
let before = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
|
assert_eq!(display::name_source(&before), NameSource::Haiku);
|
||||||
|
|
||||||
|
// Kill the tmux session directly so the liveness watcher picks it up.
|
||||||
|
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
|
||||||
|
fx.manager
|
||||||
|
.start_liveness_watcher(std::time::Duration::from_millis(25));
|
||||||
|
|
||||||
|
common::wait_for_async(std::time::Duration::from_secs(3), || async {
|
||||||
|
fx.manager.status(id).await == Some(SessionStatus::Dead)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let after = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
display::name_source(&after),
|
||||||
|
NameSource::Haiku,
|
||||||
|
"mark_session_dead must not overwrite a haiku rename in the sidecar"
|
||||||
|
);
|
||||||
|
assert_eq!(display::custom_name(&after), Some("haiku-name"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
|
|||||||
branch: branch.into(),
|
branch: branch.into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ async fn embedded_index_is_real_spa_not_placeholder() {
|
|||||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
html.contains(r#"id="app""#),
|
html.contains(r#"id="root""#),
|
||||||
"served index is missing the SPA mount point; got:\n{html}"
|
"served index is missing the SPA mount point; got:\n{html}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!html.contains("maestro is running"),
|
!html.contains("maestro is running"),
|
||||||
"served index is the placeholder, not the built bundle"
|
"served index is the placeholder, not the built bundle"
|
||||||
);
|
);
|
||||||
// Stronger signal than `id="app"` (which appears in both source and built
|
// Stronger signal than `id="root"` (which appears in both source and built
|
||||||
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
|
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
|
||||||
// bundle, whereas the un-built source references `/src/main.ts`. This catches
|
// bundle, whereas the un-built source references `/src/main.ts`. This catches
|
||||||
// both an empty/placeholder embed and the staleness case.
|
// both an empty/placeholder embed and the staleness case.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"user_message_chunk","messageId":"user-1","content":{"type":"text","text":"Inspect the repository"}}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_thought_chunk","messageId":"thought-1","content":{"type":"text","text":"I should inspect the files first."}}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"command-1","kind":"execute","title":"rg TODO","status":"in_progress","rawInput":{"command":"rg TODO","cwd":"/workspace"}}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"command-1","status":"completed","rawOutput":{"formatted_output":"src/lib.rs: TODO","exit_code":0}}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"change-1","kind":"edit","title":"Editing files","status":"completed","content":[{"type":"diff","path":"src/lib.rs","oldText":"old","newText":"new"}]}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"mcp-1","kind":"execute","title":"mcp.github.search","status":"completed","rawInput":{"server":"github","tool":"search","arguments":{"query":"ACP"}},"rawOutput":{"result":"found"},"_meta":{"is_mcp_tool_call":true}}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"web-1","kind":"search","title":"Search the web for ACP","status":"completed","rawInput":{"type":"webSearch","query":"Agent Client Protocol"}}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-view-1","kind":"read","title":"View Image /workspace/diagram.png","status":"completed","content":[{"type":"content","content":{"type":"resource_link","name":"/workspace/diagram.png","uri":"/workspace/diagram.png"}}]}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-gen-1","title":"Image generation","status":"completed","content":[{"type":"content","content":{"type":"image","data":"aW1hZ2U=","mimeType":"image/png"}}],"rawOutput":{"path":"/workspace/generated.png"}}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"plan","entries":[{"content":"Implement compatibility fixture","priority":"medium","status":"in_progress"}]}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"usage_update","used":1200,"size":258400}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"current_mode_update","currentModeId":"agent"}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"config_option_update","configOptions":[{"id":"agent-mode","name":"Agent mode","category":"mode","type":"select","currentValue":"agent","options":[{"value":"read-only","name":"Read-only"},{"value":"agent","name":"Agent"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-5.3-codex","options":[{"value":"gpt-5.3-codex","name":"GPT-5.3-Codex"}]},{"id":"reasoning_effort","name":"Reasoning effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"high","name":"High"}]},{"id":"fast-mode","name":"Fast mode","category":"model_config","type":"select","currentValue":"off","options":[{"value":"off","name":"Off"}]}]}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"status","description":"Show session status"}]}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"session_info_update","title":"Codex session"}}
|
||||||
|
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_message_chunk","messageId":"assistant-1","content":{"type":"text","text":"Done."}}}
|
||||||
@@ -4,12 +4,17 @@ mod common;
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::{Ipv4Addr, SocketAddr};
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use common::DaemonFixture;
|
use common::DaemonFixture;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_daemon::local_api;
|
use maestro_daemon::local_api;
|
||||||
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
|
use maestro_protocol::{
|
||||||
|
ActivityState, ApiError, CreateSessionRequest, Session, SessionStatus, SessionView,
|
||||||
|
WorktreeSpec,
|
||||||
|
};
|
||||||
|
use tempfile::TempDir;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
@@ -86,6 +91,640 @@ fn ws_url(addr: SocketAddr, path: &str) -> String {
|
|||||||
format!("ws://{addr}{path}")
|
format!("ws://{addr}{path}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct FakeAcp {
|
||||||
|
dir: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeAcp {
|
||||||
|
fn new() -> Self {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let bin = dir.path().join("bin");
|
||||||
|
std::fs::create_dir_all(&bin).unwrap();
|
||||||
|
let adapter = bin.join("claude-code-acp");
|
||||||
|
std::fs::copy(
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../maestro-acp/tests/fake_acp_agent.py"),
|
||||||
|
&adapter,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
Self { dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env(&self) -> HashMap<String, String> {
|
||||||
|
HashMap::from([
|
||||||
|
(
|
||||||
|
"PATH".into(),
|
||||||
|
format!(
|
||||||
|
"{}:{}",
|
||||||
|
self.dir.path().join("bin").display(),
|
||||||
|
std::env::var("PATH").unwrap()
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"FAKE_ACP_LOG".into(),
|
||||||
|
self.dir.path().join("agent.log").display().to_string(),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pid(&self) -> u32 {
|
||||||
|
std::fs::read_to_string(self.dir.path().join("agent.log"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
line.strip_prefix("start pid=")?
|
||||||
|
.split(' ')
|
||||||
|
.next()?
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.next_back()
|
||||||
|
.expect("fake ACP started")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self) -> String {
|
||||||
|
std::fs::read_to_string(self.dir.path().join("agent.log")).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
||||||
|
structured_session_with_env(h, fake.env()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
||||||
|
let id = h
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
view: SessionView::Structured,
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
command: "claude".into(),
|
||||||
|
env,
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
wait_for_session_activity(h, id, ActivityState::Idle).await;
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if sessions
|
||||||
|
.iter()
|
||||||
|
.find(|session| session.id == id)
|
||||||
|
.is_some_and(|session| session.activity == expected)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("session {id} did not reach {expected:?}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_ws_sends_snapshot_then_live_projection() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let id = structured_session(&h, &fake).await;
|
||||||
|
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let first = ws.next().await.unwrap().unwrap().into_text().unwrap();
|
||||||
|
let second = ws.next().await.unwrap().unwrap().into_text().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<serde_json::Value>(&first).unwrap()["type"],
|
||||||
|
"transcript_snapshot"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<serde_json::Value>(&second).unwrap()["type"],
|
||||||
|
"reduced_state"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/prompt"),
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({"prompt": "hello"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
let live = tokio::time::timeout(Duration::from_secs(1), ws.next())
|
||||||
|
.await
|
||||||
|
.expect("projection update arrives on the subscribed stream")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.into_text()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<serde_json::Value>(&live).unwrap()["type"],
|
||||||
|
"projection_update"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_attachments_preserve_bytes_and_reject_paths() {
|
||||||
|
let h = spawn("attachments").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let id = structured_session(&h, &fake).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let endpoint = url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/attachments"),
|
||||||
|
);
|
||||||
|
let bytes = vec![0, 255, 10, 13];
|
||||||
|
let response = client
|
||||||
|
.post(&endpoint)
|
||||||
|
.query(&[("name", "sample.bin")])
|
||||||
|
.body(bytes.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
let body: serde_json::Value = response.json().await.unwrap();
|
||||||
|
let path = body["path"].as_str().unwrap();
|
||||||
|
assert!(Path::new(path).is_absolute());
|
||||||
|
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||||
|
for name in ["../escape", "a/b", "a\\b", "..", ""] {
|
||||||
|
let response = client
|
||||||
|
.post(&endpoint)
|
||||||
|
.query(&[("name", name)])
|
||||||
|
.body("text")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), 400, "{name}");
|
||||||
|
}
|
||||||
|
let response = client
|
||||||
|
.post(&endpoint)
|
||||||
|
.query(&[("name", "large.bin")])
|
||||||
|
.body(vec![0; 20 * 1024 * 1024 + 1])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), 413);
|
||||||
|
let response = client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
|
||||||
|
))
|
||||||
|
.query(&[("name", "file.txt")])
|
||||||
|
.body("text")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_prompt_queues_during_active_turn_and_cancel_clears_it() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let mut env = fake.env();
|
||||||
|
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "200".into());
|
||||||
|
let id = structured_session_with_env(&h, env).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let first = client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/prompt"),
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({"prompt": "first"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
// A second prompt while a turn is active is accepted into the queue.
|
||||||
|
let second = client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/prompt"),
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({"prompt": "second"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(second.status(), reqwest::StatusCode::ACCEPTED);
|
||||||
|
|
||||||
|
let cancel = client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/cancel"),
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
// After cancel + settlement, the queued "second" prompt drains. Wait for
|
||||||
|
// both the first and the drained prompt to reach ACP.
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
if fake.log().matches("recv session/prompt").count() == 2 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("first and drained queued prompt both reach ACP");
|
||||||
|
let log = fake.log();
|
||||||
|
let first_prompt = log.find("recv session/prompt").unwrap();
|
||||||
|
let cancel = log.find("recv session/cancel").unwrap();
|
||||||
|
let second_prompt = log.rfind("recv session/prompt").unwrap();
|
||||||
|
assert!(first_prompt < cancel && cancel < second_prompt, "{log}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_approval_is_secret_single_use_and_race_safe() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let mut env = fake.env();
|
||||||
|
env.insert("FAKE_ACP_REQUEST_PERMISSION".into(), "1".into());
|
||||||
|
let id = structured_session_with_env(&h, env).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ws.next().await.unwrap().unwrap();
|
||||||
|
ws.next().await.unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/prompt")
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({"prompt":"needs permission"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status(),
|
||||||
|
reqwest::StatusCode::NO_CONTENT
|
||||||
|
);
|
||||||
|
|
||||||
|
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
let event: serde_json::Value =
|
||||||
|
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
if let Some(nonce) = event["state"]["pending_approval"]["nonce"].as_str() {
|
||||||
|
assert_eq!(event["transcript"]["row"]["kind"], "approval");
|
||||||
|
assert!(event["transcript"]["row"]["data"].get("nonce").is_none());
|
||||||
|
break nonce.to_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
wait_for_session_activity(&h, id, ActivityState::AwaitingInput).await;
|
||||||
|
assert!(
|
||||||
|
!fake.log().contains(&nonce),
|
||||||
|
"daemon nonce leaked to ACP agent"
|
||||||
|
);
|
||||||
|
|
||||||
|
let path = format!("/api/sessions/{id}/structured/approvals/{nonce}");
|
||||||
|
let (a, b) = tokio::join!(
|
||||||
|
client
|
||||||
|
.post(url(h.addr, &path))
|
||||||
|
.json(&serde_json::json!("allow_once"))
|
||||||
|
.send(),
|
||||||
|
client
|
||||||
|
.post(url(h.addr, &path))
|
||||||
|
.json(&serde_json::json!("deny"))
|
||||||
|
.send(),
|
||||||
|
);
|
||||||
|
assert_eq!(a.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
assert_eq!(b.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.post(url(h.addr, &path))
|
||||||
|
.json(&serde_json::json!("deny"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status(),
|
||||||
|
reqwest::StatusCode::NO_CONTENT
|
||||||
|
);
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
if fake.log().contains("permission response") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let log = fake.log();
|
||||||
|
assert!(
|
||||||
|
log.contains("\"optionId\": \"once\"") || log.contains("\"optionId\": \"deny\""),
|
||||||
|
"{log}"
|
||||||
|
);
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
let event: serde_json::Value =
|
||||||
|
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
if event["state"]["pending_approval"].is_null() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("resolved approval clears the reduced state");
|
||||||
|
wait_for_session_activity(&h, id, ActivityState::Idle).await;
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!(
|
||||||
|
"/api/sessions/{}/structured/approvals/{nonce}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
)
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!("deny"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status(),
|
||||||
|
reqwest::StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_prompt_failure_clears_the_turn() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let mut env = fake.env();
|
||||||
|
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
|
||||||
|
let id = structured_session_with_env(&h, env).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let path = format!("/api/sessions/{id}/structured/prompt");
|
||||||
|
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ws.next().await.unwrap().unwrap();
|
||||||
|
ws.next().await.unwrap().unwrap();
|
||||||
|
let first = client
|
||||||
|
.post(url(h.addr, &path))
|
||||||
|
.json(&serde_json::json!({"prompt":"fails"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
let mut saw_active_turn = false;
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
while let Some(Ok(message)) = ws.next().await {
|
||||||
|
let event: serde_json::Value =
|
||||||
|
serde_json::from_str(&message.into_text().unwrap()).unwrap();
|
||||||
|
let turn = &event["state"]["turn"];
|
||||||
|
if turn["responding"] == true {
|
||||||
|
saw_active_turn = true;
|
||||||
|
}
|
||||||
|
if saw_active_turn && turn["responding"] == false && turn["thinking"] == false {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("structured websocket closed before the failed turn cleared");
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("failed turn clears promptly");
|
||||||
|
wait_for_session_activity(&h, id, ActivityState::Error).await;
|
||||||
|
let second = client
|
||||||
|
.post(url(h.addr, &path))
|
||||||
|
.json(&serde_json::json!({"prompt":"available"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(second.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
assert!(second.bytes().await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let prompt = |id| {
|
||||||
|
client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/prompt"),
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({"prompt": "hello"}))
|
||||||
|
};
|
||||||
|
|
||||||
|
let unknown = prompt(Uuid::new_v4()).send().await.unwrap();
|
||||||
|
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
|
||||||
|
let error: ApiError = unknown.json().await.unwrap();
|
||||||
|
assert_eq!(error.error.code, "not_found");
|
||||||
|
|
||||||
|
let terminal = h
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
view: SessionView::Terminal,
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
command: "sh".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let non_structured = prompt(terminal.id).send().await.unwrap();
|
||||||
|
assert_eq!(non_structured.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
let error: ApiError = non_structured.json().await.unwrap();
|
||||||
|
assert_eq!(error.error.code, "not_structured");
|
||||||
|
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let dead = structured_session(&h, &fake).await;
|
||||||
|
h.manager.shutdown().await;
|
||||||
|
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
|
||||||
|
let unavailable = prompt(dead).send().await.unwrap();
|
||||||
|
assert_eq!(unavailable.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
let error: ApiError = unavailable.json().await.unwrap();
|
||||||
|
assert_eq!(error.error.code, "not_structured");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_replay_honors_cursors_and_limit() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let id = structured_session(&h, &fake).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
let events: Vec<serde_json::Value> = client
|
||||||
|
.get(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/replay?since=0&limit=1"),
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if !events.is_empty() {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert_eq!(events[0]["cursor"], 1);
|
||||||
|
assert!(events[0].get("notification").is_none());
|
||||||
|
|
||||||
|
let invalid = client
|
||||||
|
.get(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/replay?since=0&before=2"),
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_replay_pages_projected_events_in_cursor_order() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let id = structured_session(&h, &fake).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
unsafe { libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1) };
|
||||||
|
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
let events: Vec<serde_json::Value> = client
|
||||||
|
.get(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/replay?before=99&limit=2"),
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if events.len() == 2 {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.map(|event| event["cursor"].as_u64().unwrap())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![1, 2]
|
||||||
|
);
|
||||||
|
let boundary: Vec<serde_json::Value> = client
|
||||||
|
.get(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/replay?before=2&limit=10"),
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(boundary.len(), 1);
|
||||||
|
assert_eq!(boundary[0]["cursor"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let unknown = client
|
||||||
|
.get(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{}/structured/replay", Uuid::new_v4()),
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
|
||||||
|
let terminal = h
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
view: SessionView::Terminal,
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
command: "sh".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let response = client
|
||||||
|
.get(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{}/structured/replay", terminal.id),
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let dead = structured_session(&h, &fake).await;
|
||||||
|
h.manager.shutdown().await;
|
||||||
|
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
|
||||||
|
let response = client
|
||||||
|
.get(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{dead}/structured/replay"),
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
let error: ApiError = response.json().await.unwrap();
|
||||||
|
assert_eq!(error.error.code, "not_structured");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_ws_rejects_a_dead_session() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let id = structured_session(&h, &fake).await;
|
||||||
|
h.manager.shutdown().await;
|
||||||
|
assert_eq!(h.manager.status(id).await, Some(SessionStatus::Dead));
|
||||||
|
|
||||||
|
let err = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
||||||
|
.await
|
||||||
|
.expect_err("dead structured session must not upgrade");
|
||||||
|
let message = err.to_string();
|
||||||
|
assert!(
|
||||||
|
message.contains("409") || message.to_lowercase().contains("conflict"),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn info_returns_daemon_identity() {
|
async fn info_returns_daemon_identity() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
@@ -180,6 +819,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
let created: Session = reqwest::Client::new()
|
let created: Session = reqwest::Client::new()
|
||||||
.post(url(h.addr, "/api/sessions"))
|
.post(url(h.addr, "/api/sessions"))
|
||||||
@@ -354,7 +994,9 @@ async fn names_honors_count_and_caps_it() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gitroot_probes_path() {
|
async fn gitroot_probes_path() {
|
||||||
// #26: resolves the query against the daemon home dir and walks up for a
|
// #26: resolves the query against the daemon home dir and walks up for a
|
||||||
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
|
// `.git`. The tempdir itself has no `.git`, so the result must either be
|
||||||
|
// null (no repo anywhere above) or — in sandboxed environments where /tmp
|
||||||
|
// itself sits inside a repo — a path strictly above the tempdir.
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let resp: serde_json::Value = reqwest::get(url(
|
let resp: serde_json::Value = reqwest::get(url(
|
||||||
@@ -369,7 +1011,16 @@ async fn gitroot_probes_path() {
|
|||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(resp.get("root").is_none() || resp["root"].is_null());
|
// On hosts where an ancestor of the temp dir is a repo (e.g. `/tmp`
|
||||||
|
// inside a checkout), the root legitimately points there. The test only
|
||||||
|
// asserts no root was found *inside* the temp dir.
|
||||||
|
match resp.get("root").and_then(|v| v.as_str()) {
|
||||||
|
None => {}
|
||||||
|
Some(root) => assert!(
|
||||||
|
!root.starts_with(tmp.path().to_string_lossy().as_ref()),
|
||||||
|
"found a repo root inside the fixture at {root:?}"
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -397,6 +1048,7 @@ async fn patch_session_renames_it() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -440,6 +1092,7 @@ async fn post_focus_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -474,6 +1127,7 @@ async fn delete_owned_session_kills_it() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -496,6 +1150,7 @@ async fn spawn_via_manager(h: &Harness) -> Uuid {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -610,6 +1265,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -663,6 +1319,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -693,6 +1350,7 @@ async fn attach_ws_streams_pty_bytes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -723,6 +1381,39 @@ async fn attach_ws_streams_pty_bytes() {
|
|||||||
assert!(saw_hello, "PTY did not echo through the attach WS");
|
assert!(saw_hello, "PTY did not echo through the attach WS");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pty_routes_reject_structured_sessions_as_not_terminal() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let id = structured_session(&h, &fake).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(url(h.addr, &format!("/api/sessions/{id}/input")))
|
||||||
|
.json(&maestro_protocol::SendInputRequest {
|
||||||
|
text: "ignored".into(),
|
||||||
|
submit: false,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
let error: ApiError = response.json().await.unwrap();
|
||||||
|
assert_eq!(error.error.code, "not_terminal");
|
||||||
|
assert_eq!(error.error.message, "session is not a terminal session");
|
||||||
|
|
||||||
|
let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}")))
|
||||||
|
.await
|
||||||
|
.expect_err("structured session must not upgrade to a PTY socket");
|
||||||
|
let tokio_tungstenite::tungstenite::Error::Http(response) = error else {
|
||||||
|
panic!("expected an HTTP handshake rejection, got {error}");
|
||||||
|
};
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
let body = response.body().as_deref().expect("ApiError response body");
|
||||||
|
let error: ApiError = serde_json::from_slice(body).unwrap();
|
||||||
|
assert_eq!(error.error.code, "not_terminal");
|
||||||
|
assert_eq!(error.error.message, "session is not a terminal session");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn attach_ws_for_unknown_session_returns_404() {
|
async fn attach_ws_for_unknown_session_returns_404() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
@@ -762,6 +1453,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -789,6 +1481,79 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn events_ws_streams_structured_activity_without_hook_overrides() {
|
||||||
|
let h = spawn("konishi").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let mut env = fake.env();
|
||||||
|
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "500".into());
|
||||||
|
let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events"))
|
||||||
|
.await
|
||||||
|
.expect("events ws");
|
||||||
|
assert!(matches!(
|
||||||
|
next_event(&mut ws).await,
|
||||||
|
maestro_protocol::CatalogEvent::Snapshot { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
let id = structured_session_with_env(&h, env).await;
|
||||||
|
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
|
||||||
|
panic!("expected structured SessionAdded")
|
||||||
|
};
|
||||||
|
assert_eq!(session.activity, ActivityState::Starting);
|
||||||
|
// The background handshake completes and sends SessionUpdated → Idle.
|
||||||
|
let maestro_protocol::CatalogEvent::SessionUpdated { session } = next_event(&mut ws).await
|
||||||
|
else {
|
||||||
|
panic!("expected SessionUpdated after handshake")
|
||||||
|
};
|
||||||
|
assert_eq!(session.activity, ActivityState::Idle);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let prompt = client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/prompt"),
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({"prompt": "hello"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(prompt.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
let report = client
|
||||||
|
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||||
|
.json(&serde_json::json!({"state": "Error"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(report.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
wait_for_session_activity(&h, id, ActivityState::Active).await;
|
||||||
|
|
||||||
|
let mut activities = Vec::new();
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
if let maestro_protocol::CatalogEvent::SessionUpdated { session } =
|
||||||
|
next_event(&mut ws).await
|
||||||
|
{
|
||||||
|
activities.push(session.activity);
|
||||||
|
if session.activity == ActivityState::Idle {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("structured turn returns to idle");
|
||||||
|
assert!(
|
||||||
|
activities.contains(&ActivityState::Active),
|
||||||
|
"{activities:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!activities.contains(&ActivityState::Error),
|
||||||
|
"{activities:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
h.manager.kill(id, false).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// Each subscriber has its own lag domain now that the publisher task is gone:
|
/// Each subscriber has its own lag domain now that the publisher task is gone:
|
||||||
/// two clients connected at different times both get their own Snapshot.
|
/// two clients connected at different times both get their own Snapshot.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -807,6 +1572,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ impl Fx {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async fn create_then_kill_round_trip() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -70,6 +71,7 @@ async fn create_appends_to_spawn_history() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -100,6 +102,7 @@ async fn create_without_history_logs_nothing() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -123,6 +126,7 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -166,6 +170,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -215,6 +220,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -273,6 +279,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -324,6 +331,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -387,6 +395,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -438,6 +447,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -508,6 +518,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
|||||||
branch: "feat/indicator".into(),
|
branch: "feat/indicator".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -601,6 +612,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
|||||||
branch: "feat/keep".into(),
|
branch: "feat/keep".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -639,6 +651,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -703,6 +716,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
|||||||
branch: "feat/pushed".into(),
|
branch: "feat/pushed".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -753,6 +767,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
branch: "feat/forced".into(),
|
branch: "feat/forced".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -799,6 +814,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
|||||||
branch: "feat/clear".into(),
|
branch: "feat/clear".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -840,6 +856,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
|||||||
branch: "feat/rusty".into(),
|
branch: "feat/rusty".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -882,6 +899,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
branch: "feat/exited".into(),
|
branch: "feat/exited".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -944,6 +962,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|||||||
branch: "feat/clean".into(),
|
branch: "feat/clean".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1001,6 +1020,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1040,6 +1060,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
branch: "feat/one".into(),
|
branch: "feat/one".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create first");
|
.expect("create first");
|
||||||
@@ -1058,6 +1079,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
branch: "feat/two".into(),
|
branch: "feat/two".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create second");
|
.expect("create second");
|
||||||
@@ -1093,6 +1115,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1132,6 +1155,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1158,6 +1182,7 @@ async fn set_focused_true_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1207,6 +1232,7 @@ async fn attaching_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use std::collections::HashMap;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_daemon::sidecar::SidecarStore;
|
use maestro_daemon::sidecar::{PersistedEvent, SidecarStore};
|
||||||
use maestro_protocol::{Session, SessionStatus, SessionView};
|
use maestro_protocol::{Session, SessionStatus, SessionView, TranscriptRow, TranscriptRowKind};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -34,6 +34,8 @@ fn sample(id: Uuid) -> Session {
|
|||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
view: SessionView::Terminal,
|
view: SessionView::Terminal,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,15 +70,30 @@ fn delete_removes_file_and_is_idempotent() {
|
|||||||
let s = sample(id);
|
let s = sample(id);
|
||||||
|
|
||||||
store.write(&s).unwrap();
|
store.write(&s).unwrap();
|
||||||
|
store.mark_for_recovery(id).unwrap();
|
||||||
assert!(store.path_for(id).exists());
|
assert!(store.path_for(id).exists());
|
||||||
|
assert!(store.recovery_path_for(id).exists());
|
||||||
|
|
||||||
store.delete(id).unwrap();
|
store.delete(id).unwrap();
|
||||||
assert!(!store.path_for(id).exists());
|
assert!(!store.path_for(id).exists());
|
||||||
|
assert!(!store.recovery_path_for(id).exists());
|
||||||
|
|
||||||
// second delete is a no-op
|
// second delete is a no-op
|
||||||
store.delete(id).unwrap();
|
store.delete(id).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_marker_is_consumed_once() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let store = SidecarStore::new(dir.path());
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
|
assert!(!store.take_recovery_marker(id).unwrap());
|
||||||
|
store.mark_for_recovery(id).unwrap();
|
||||||
|
assert!(store.take_recovery_marker(id).unwrap());
|
||||||
|
assert!(!store.take_recovery_marker(id).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_all_recovers_every_sidecar_and_skips_garbage() {
|
fn load_all_recovers_every_sidecar_and_skips_garbage() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -171,6 +188,13 @@ fn text_of(n: &SessionNotification) -> &str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
||||||
|
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
|
||||||
|
panic!("expected ACP notification")
|
||||||
|
};
|
||||||
|
text_of(notification)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn append_assigns_line_position_as_seq_and_reads_back() {
|
fn append_assigns_line_position_as_seq_and_reads_back() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -179,9 +203,9 @@ fn append_assigns_line_position_as_seq_and_reads_back() {
|
|||||||
|
|
||||||
let mut log = store.open_events(id).expect("open");
|
let mut log = store.open_events(id).expect("open");
|
||||||
assert_eq!(log.last_seq(), 0);
|
assert_eq!(log.last_seq(), 0);
|
||||||
assert_eq!(log.append(¬e("a")).unwrap(), 1);
|
assert_eq!(log.append(note("a")).unwrap(), 1);
|
||||||
assert_eq!(log.append(¬e("b")).unwrap(), 2);
|
assert_eq!(log.append(note("b")).unwrap(), 2);
|
||||||
assert_eq!(log.append(¬e("c")).unwrap(), 3);
|
assert_eq!(log.append(note("c")).unwrap(), 3);
|
||||||
assert_eq!(log.last_seq(), 3);
|
assert_eq!(log.last_seq(), 3);
|
||||||
|
|
||||||
let path = store.events_path_for(id);
|
let path = store.events_path_for(id);
|
||||||
@@ -196,9 +220,7 @@ fn append_assigns_line_position_as_seq_and_reads_back() {
|
|||||||
let all = store.read_events(id, EventCursor::Since(0), 100).unwrap();
|
let all = store.read_events(id, EventCursor::Since(0), 100).unwrap();
|
||||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
|
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
all.iter()
|
all.iter().map(event_text).collect::<Vec<_>>(),
|
||||||
.map(|e| text_of(&e.notification))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec!["a", "b", "c"]
|
vec!["a", "b", "c"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -210,7 +232,7 @@ fn since_reads_forward_and_before_reads_backward_with_limit() {
|
|||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
for i in 1..=6 {
|
for i in 1..=6 {
|
||||||
log.append(¬e(&format!("n{i}"))).unwrap();
|
log.append(note(&format!("n{i}"))).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// `since` is exclusive: what a client that already holds seq 2 needs.
|
// `since` is exclusive: what a client that already holds seq 2 needs.
|
||||||
@@ -263,7 +285,7 @@ fn malformed_lines_are_skipped_but_keep_their_seq() {
|
|||||||
let store = SidecarStore::new(dir.path());
|
let store = SidecarStore::new(dir.path());
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
log.append(¬e("first")).unwrap();
|
log.append(note("first")).unwrap();
|
||||||
drop(log);
|
drop(log);
|
||||||
|
|
||||||
// Garbage in the middle and a line that is JSON but not a notification.
|
// Garbage in the middle and a line that is JSON but not a notification.
|
||||||
@@ -274,11 +296,11 @@ fn malformed_lines_are_skipped_but_keep_their_seq() {
|
|||||||
std::fs::write(&path, raw).unwrap();
|
std::fs::write(&path, raw).unwrap();
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
assert_eq!(log.last_seq(), 3);
|
assert_eq!(log.last_seq(), 3);
|
||||||
assert_eq!(log.append(¬e("fourth")).unwrap(), 4);
|
assert_eq!(log.append(note("fourth")).unwrap(), 4);
|
||||||
|
|
||||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
||||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 4]);
|
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 4]);
|
||||||
assert_eq!(text_of(&all[1].notification), "fourth");
|
assert_eq!(event_text(&all[1]), "fourth");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -288,7 +310,7 @@ fn truncated_last_line_still_opens_and_the_next_append_starts_a_fresh_line() {
|
|||||||
let store = SidecarStore::new(dir.path());
|
let store = SidecarStore::new(dir.path());
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
log.append(¬e("whole")).unwrap();
|
log.append(note("whole")).unwrap();
|
||||||
drop(log);
|
drop(log);
|
||||||
|
|
||||||
let path = store.events_path_for(id);
|
let path = store.events_path_for(id);
|
||||||
@@ -304,10 +326,10 @@ fn truncated_last_line_still_opens_and_the_next_append_starts_a_fresh_line() {
|
|||||||
// glue onto it; the partial keeps a seq of its own.
|
// glue onto it; the partial keeps a seq of its own.
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
assert_eq!(log.last_seq(), 2);
|
assert_eq!(log.last_seq(), 2);
|
||||||
assert_eq!(log.append(¬e("after")).unwrap(), 3);
|
assert_eq!(log.append(note("after")).unwrap(), 3);
|
||||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
||||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
|
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
|
||||||
assert_eq!(text_of(&all[1].notification), "after");
|
assert_eq!(event_text(&all[1]), "after");
|
||||||
let raw = std::fs::read_to_string(&path).unwrap();
|
let raw = std::fs::read_to_string(&path).unwrap();
|
||||||
assert_eq!(raw.lines().count(), 3, "{raw}");
|
assert_eq!(raw.lines().count(), 3, "{raw}");
|
||||||
}
|
}
|
||||||
@@ -319,8 +341,10 @@ fn delete_removes_the_event_log_and_load_all_ignores_it() {
|
|||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
store.write(&sample(id)).unwrap();
|
store.write(&sample(id)).unwrap();
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
log.append(¬e("x")).unwrap();
|
log.append(note("x")).unwrap();
|
||||||
drop(log);
|
drop(log);
|
||||||
|
let legacy_approvals = dir.path().join(format!("{id}.approvals.jsonl"));
|
||||||
|
std::fs::write(&legacy_approvals, b"obsolete").unwrap();
|
||||||
assert!(store.events_path_for(id).exists());
|
assert!(store.events_path_for(id).exists());
|
||||||
|
|
||||||
let loaded = store.load_all().unwrap();
|
let loaded = store.load_all().unwrap();
|
||||||
@@ -329,6 +353,47 @@ fn delete_removes_the_event_log_and_load_all_ignores_it() {
|
|||||||
store.delete(id).unwrap();
|
store.delete(id).unwrap();
|
||||||
assert!(!store.path_for(id).exists());
|
assert!(!store.path_for(id).exists());
|
||||||
assert!(!store.events_path_for(id).exists());
|
assert!(!store.events_path_for(id).exists());
|
||||||
|
assert!(!legacy_approvals.exists());
|
||||||
// Idempotent with the log already gone.
|
// Idempotent with the log already gone.
|
||||||
store.delete(id).unwrap();
|
store.delete(id).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approval_rows_share_event_cursors_with_legacy_acp_lines() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let store = SidecarStore::new(dir.path());
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let legacy = serde_json::to_vec(¬e("before")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path()).unwrap();
|
||||||
|
std::fs::write(store.events_path_for(id), [legacy, b"\n".to_vec()].concat()).unwrap();
|
||||||
|
|
||||||
|
let mut log = store.open_events(id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
log.append(PersistedEvent::Approval {
|
||||||
|
row: TranscriptRow {
|
||||||
|
id: "approval:secret".into(),
|
||||||
|
kind: TranscriptRowKind::Approval,
|
||||||
|
data: serde_json::json!({"tool": "bash"}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(log.append(note("after")).unwrap(), 3);
|
||||||
|
|
||||||
|
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
all.iter().map(|event| event.seq).collect::<Vec<_>>(),
|
||||||
|
[1, 2, 3]
|
||||||
|
);
|
||||||
|
assert!(matches!(all[1].event, PersistedEvent::Approval { .. }));
|
||||||
|
assert_eq!(event_text(&all[0]), "before");
|
||||||
|
assert_eq!(event_text(&all[2]), "after");
|
||||||
|
|
||||||
|
let since = store.read_events(id, EventCursor::Since(1), 1).unwrap();
|
||||||
|
assert_eq!(since[0].seq, 2);
|
||||||
|
let before = store
|
||||||
|
.read_events(id, EventCursor::Before(Some(3)), 1)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(before[0].seq, 2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async fn root_serves_the_spa_shell() {
|
|||||||
.map(|v| v.starts_with("text/html")),
|
.map(|v| v.starts_with("text/html")),
|
||||||
Some(true)
|
Some(true)
|
||||||
);
|
);
|
||||||
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -69,7 +69,7 @@ async fn client_side_route_falls_back_to_the_shell() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), 200);
|
assert_eq!(resp.status(), 200);
|
||||||
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An unmatched API path must 404 rather than fall through to the SPA shell:
|
/// An unmatched API path must 404 rather than fall through to the SPA shell:
|
||||||
|
|||||||
@@ -405,15 +405,17 @@ fn run_git(dir: &std::path::Path, args: &[&str]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 49ab7e9: a new-branch add onto a name that already exists suffixes the
|
/// `git worktree add -b` alone fails outright when the branch already exists,
|
||||||
/// branch (and so the directory) instead of failing, per docs/WORKTREES.md.
|
/// even with the target directory free — a random handle commonly collides
|
||||||
|
/// with one an earlier, already-removed worktree left behind. `add_new`
|
||||||
|
/// sidesteps it on the branch name itself instead of erroring (#77).
|
||||||
#[test]
|
#[test]
|
||||||
fn add_new_suffixes_when_branch_already_exists() {
|
fn add_new_suffixes_branch_name_on_branch_collision() {
|
||||||
let repo_dir = TempDir::new().unwrap();
|
let repo_dir = TempDir::new().unwrap();
|
||||||
let repo = repo_dir.path().to_path_buf();
|
let repo = repo_dir.path().to_path_buf();
|
||||||
init_repo(&repo);
|
init_repo(&repo);
|
||||||
|
|
||||||
// pre-create the branch
|
// pre-create the branch, unused by any worktree.
|
||||||
let status = Command::new("git")
|
let status = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo)
|
.arg(&repo)
|
||||||
@@ -425,9 +427,17 @@ fn add_new_suffixes_when_branch_already_exists() {
|
|||||||
let wt_root = TempDir::new().unwrap();
|
let wt_root = TempDir::new().unwrap();
|
||||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||||
|
|
||||||
let path = mgr.add_new(&repo, "feat/dup").unwrap();
|
let path = mgr
|
||||||
|
.add_new(&repo, "feat/dup")
|
||||||
|
.expect("sidesteps the branch collision");
|
||||||
assert!(path.ends_with("feat/dup-2"), "{}", path.display());
|
assert!(path.ends_with("feat/dup-2"), "{}", path.display());
|
||||||
assert!(path.is_dir());
|
assert!(path.exists());
|
||||||
|
|
||||||
|
// The pre-existing branch is untouched; the new worktree checked out the
|
||||||
|
// suffixed one instead of erroring or adopting `feat/dup`.
|
||||||
|
let branches = worktree_branches(&repo);
|
||||||
|
assert!(branches.contains(&"feat/dup-2".to_string()), "{branches:?}");
|
||||||
|
assert!(!branches.contains(&"feat/dup".to_string()), "{branches:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
|
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base, branch, create_branch)),
|
worktree: Some(worktree_spec(base, branch, create_branch)),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +208,7 @@ async fn kill_removes_clean_worktree() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
@@ -260,6 +263,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
@@ -319,6 +323,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client
|
let s = client
|
||||||
@@ -383,13 +388,11 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
|||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #66: a second spawn onto an *existing* branch that already has a worktree
|
/// #66/#77: a second spawn onto the same branch used to be rejected outright;
|
||||||
/// is rejected with a structured worktree error; the first worktree stays
|
/// since the branch-collision suffixing landed, it succeeds instead — the
|
||||||
/// intact. (A second `create_branch: true` spawn is a different case: 49ab7e9
|
/// second worktree lands on `<branch>-2` and the first stays untouched.
|
||||||
/// suffixes the new branch name instead, see
|
|
||||||
/// `colliding_branch_name_gets_suffixed_too`.)
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn second_spawn_on_same_branch_is_rejected() {
|
async fn second_spawn_on_same_branch_gets_suffixed() {
|
||||||
let (_daemon, client) = bring_up().await;
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
let base = TempDir::new().unwrap();
|
let base = TempDir::new().unwrap();
|
||||||
@@ -399,28 +402,33 @@ async fn second_spawn_on_same_branch_is_rejected() {
|
|||||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||||
.await
|
.await
|
||||||
.expect("first spawn");
|
.expect("first spawn");
|
||||||
let dup = client
|
let second = client
|
||||||
.spawn_session(&worktree_req(base.path(), "feature/dup", false))
|
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||||
.await;
|
.await
|
||||||
match dup {
|
.expect("second spawn suffixes instead of rejecting");
|
||||||
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
|
|
||||||
assert_eq!(
|
|
||||||
info.code, "worktree_failed",
|
|
||||||
"expected worktree error: {info:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
other => panic!("expected rejection for duplicate branch, got {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exactly one registration, one directory, still usable.
|
let suffixed = base.path().join("worktrees").join("feature").join("dup-2");
|
||||||
// Count branch records only: the worktree path now contains the branch
|
assert_eq!(
|
||||||
// name too (`worktrees/feature/dup`), so a substring match over every line
|
second.cwd, suffixed,
|
||||||
// would count the same tree twice.
|
"second worktree must land on the suffixed path"
|
||||||
let holders = git_worktree_list(base.path())
|
);
|
||||||
.iter()
|
assert!(suffixed.is_dir());
|
||||||
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
|
|
||||||
.count();
|
// Both registrations exist: the original branch, untouched, and the
|
||||||
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
|
// suffixed one the second spawn created.
|
||||||
|
let registrations = git_worktree_list(base.path());
|
||||||
|
assert!(
|
||||||
|
registrations
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.as_str() == "branch refs/heads/feature/dup"),
|
||||||
|
"original branch must stay registered: {registrations:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
registrations
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.as_str() == "branch refs/heads/feature/dup-2"),
|
||||||
|
"suffixed branch must be registered too: {registrations:?}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
base.path()
|
base.path()
|
||||||
.join("worktrees")
|
.join("worktrees")
|
||||||
@@ -431,7 +439,8 @@ async fn second_spawn_on_same_branch_is_rejected() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let _origin = throwaway_origin(base.path());
|
let _origin = throwaway_origin(base.path());
|
||||||
client.kill_session(first.id).await.expect("kill");
|
client.kill_session(first.id).await.expect("kill first");
|
||||||
|
client.kill_session(second.id).await.expect("kill second");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
|
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
|
||||||
@@ -768,7 +777,7 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
|||||||
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force overrides the refusal and nothing else: session gone, tree kept.
|
// Force overrides the refusal: session gone, tree removed.
|
||||||
client.kill_session_forced(s.id).await.expect("forced kill");
|
client.kill_session_forced(s.id).await.expect("forced kill");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -780,12 +789,12 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
|||||||
.any(|x| x.id == s.id),
|
.any(|x| x.id == s.id),
|
||||||
"the forced kill ends the session"
|
"the forced kill ends the session"
|
||||||
);
|
);
|
||||||
assert!(wt.is_dir(), "a guarded worktree must stay on disk");
|
assert!(!wt.is_dir(), "a forced kill removes the worktree");
|
||||||
assert!(
|
assert!(
|
||||||
git_worktree_list(base.path())
|
!git_worktree_list(base.path())
|
||||||
.iter()
|
.iter()
|
||||||
.any(|l| l.contains("feature/local")),
|
.any(|l| l.contains("feature/local")),
|
||||||
"the kept tree stays registered in git"
|
"the removed tree is unregistered from git"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,6 +832,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("spawn dirty");
|
.expect("spawn dirty");
|
||||||
@@ -872,6 +882,7 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(cwd, branch, true)),
|
worktree: Some(worktree_spec(cwd, branch, true)),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1064,6 +1075,7 @@ async fn sigterm_stops_structured_child_before_events_ws_drains() {
|
|||||||
env: fake.env.clone(),
|
env: fake.env.clone(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("spawn structured session");
|
.expect("spawn structured session");
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use crate::routes::{self, UrlError};
|
|||||||
use crate::{
|
use crate::{
|
||||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
||||||
SetFocusRequest,
|
SetFocusRequest, StructuredApprovalDecision, StructuredPromptRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -105,6 +105,11 @@ impl DaemonClient {
|
|||||||
Ok(routes::attach_url(&self.base, id)?)
|
Ok(routes::attach_url(&self.base, id)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `WS /api/structured/:id` URL for this daemon.
|
||||||
|
pub fn structured_url(&self, id: Uuid) -> Result<String, ClientError> {
|
||||||
|
Ok(routes::structured_url(&self.base, id)?)
|
||||||
|
}
|
||||||
|
|
||||||
// -- typed calls --------------------------------------------------------
|
// -- typed calls --------------------------------------------------------
|
||||||
|
|
||||||
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
|
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
|
||||||
@@ -179,6 +184,57 @@ impl DaemonClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send one prompt to a structured session. The daemon rejects a second
|
||||||
|
/// prompt while the preceding ACP turn is still active.
|
||||||
|
pub async fn send_structured_prompt(&self, id: Uuid, prompt: &str) -> Result<(), ClientError> {
|
||||||
|
let req = StructuredPromptRequest {
|
||||||
|
prompt: prompt.to_string(),
|
||||||
|
};
|
||||||
|
let resp = self
|
||||||
|
.send(
|
||||||
|
Method::POST,
|
||||||
|
&routes::structured_prompt(id),
|
||||||
|
&[],
|
||||||
|
Some(&req),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the structured session's active ACP turn.
|
||||||
|
pub async fn cancel_structured_turn(&self, id: Uuid) -> Result<(), ClientError> {
|
||||||
|
let resp = self
|
||||||
|
.send(
|
||||||
|
Method::POST,
|
||||||
|
&routes::structured_cancel(id),
|
||||||
|
&[],
|
||||||
|
None::<&()>,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the currently pending structured-session permission request.
|
||||||
|
pub async fn resolve_structured_approval(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
nonce: &str,
|
||||||
|
decision: StructuredApprovalDecision,
|
||||||
|
) -> Result<(), ClientError> {
|
||||||
|
let resp = self
|
||||||
|
.send(
|
||||||
|
Method::POST,
|
||||||
|
&routes::structured_approval(id, nonce),
|
||||||
|
&[],
|
||||||
|
Some(&decision),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
||||||
self.get(routes::INFO, &[]).await
|
self.get(routes::INFO, &[]).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,45 +166,32 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
|||||||
cur
|
cur
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Command wrappers that run another program: the program is what follows.
|
|
||||||
const WRAPPERS: [&str; 8] = [
|
|
||||||
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The program a command line runs, basenamed and stripped of its arguments —
|
/// The program a command line runs, basenamed and stripped of its arguments —
|
||||||
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
|
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
|
||||||
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
|
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
|
||||||
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
|
/// `npx`, `pnpm dlx`, …) are looked through, and quoting/escaping is honoured
|
||||||
|
/// (never evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a
|
||||||
|
/// shell would need real grammar for (pipelines, `&&`/`;`, redirections,
|
||||||
|
/// substitution, an unterminated quote) falls back to the naive first word
|
||||||
|
/// rather than guessing through it (#199). Mirrors the web's `splitCommand`.
|
||||||
pub fn command_program(command: &str) -> String {
|
pub fn command_program(command: &str) -> String {
|
||||||
let words: Vec<&str> = command.split_whitespace().collect();
|
match resolve(command) {
|
||||||
let mut i = 0;
|
Some(r) => match r.words.get(r.program_index) {
|
||||||
while i < words.len() {
|
Some(word) => basename(word).to_string(),
|
||||||
let word = words[i];
|
// Nothing but wrappers and assignments: say what was there
|
||||||
if is_env_assignment(word) {
|
// rather than rendering an empty fragment.
|
||||||
i += 1;
|
None => command.trim().to_string(),
|
||||||
continue;
|
},
|
||||||
}
|
None => legacy_first_word(command),
|
||||||
let wrapper = basename(word).to_lowercase();
|
|
||||||
if WRAPPERS.contains(&wrapper.as_str()) {
|
|
||||||
i += 1;
|
|
||||||
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match words.get(i) {
|
|
||||||
Some(word) => basename(word).to_string(),
|
|
||||||
// Nothing but wrappers and assignments: say what was there rather than
|
|
||||||
// rendering an empty fragment.
|
|
||||||
None => command.trim().to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
||||||
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
||||||
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
||||||
|
/// `claude-code-acp` — the daemon's actual structured-session child — is
|
||||||
|
/// deliberately absent: the daemon launches it directly and ignores
|
||||||
|
/// `req.command`, so it is never itself a valid user-facing launch form.
|
||||||
const BY_PROGRAM: [(&str, &str); 17] = [
|
const BY_PROGRAM: [(&str, &str); 17] = [
|
||||||
("claude", "claude"),
|
("claude", "claude"),
|
||||||
("claude-code", "claude"),
|
("claude-code", "claude"),
|
||||||
@@ -226,29 +213,500 @@ const BY_PROGRAM: [(&str, &str); 17] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// The tool id a command line's icon stands for (`claude`, `codex`,
|
/// The tool id a command line's icon stands for (`claude`, `codex`,
|
||||||
/// `opencode`, ...), or `None` when the program is not one we have a mark for.
|
/// `opencode`, ...), or `None` when the program is not one we have a mark for
|
||||||
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
|
/// — including a command line [`resolve`] would only guess at. The command
|
||||||
/// the same tools the web's `splitCommand().tool` does.
|
/// bar matches this field (`docs/COMMAND-BAR.md`), so it must name the same
|
||||||
|
/// tools the web's `splitCommand().tool` does.
|
||||||
pub fn command_tool(command: &str) -> Option<&'static str> {
|
pub fn command_tool(command: &str) -> Option<&'static str> {
|
||||||
let program = command_program(command).to_lowercase();
|
let r = resolve(command)?;
|
||||||
|
let program = basename(r.words.get(r.program_index)?).to_lowercase();
|
||||||
BY_PROGRAM
|
BY_PROGRAM
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(name, _)| *name == program)
|
.find(|(name, _)| *name == program)
|
||||||
.map(|(_, tool)| *tool)
|
.map(|(_, tool)| *tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
/// Whether a command line resolves to an agent Maestro can run as a
|
||||||
fn is_env_assignment(word: &str) -> bool {
|
/// structured (ACP) session — Claude Code or Codex, the two adapters
|
||||||
let mut chars = word.chars();
|
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
|
||||||
|
/// control on this, and the daemon rejects a `Structured` request for
|
||||||
|
/// anything else, so the answer has to come from one place.
|
||||||
|
pub fn supports_structured_view(command: &str) -> bool {
|
||||||
|
matches!(command_tool(command), Some("claude" | "codex"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
|
||||||
|
/// of the word that is the actual program (past every assignment/wrapper it
|
||||||
|
/// resolved through).
|
||||||
|
struct Resolution {
|
||||||
|
words: Vec<String>,
|
||||||
|
program_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokenize `command` and walk past leading `FOO=bar` assignments and known
|
||||||
|
/// wrapper forms (interleaved in any order/count) to find the program. An
|
||||||
|
/// assignment run is only consumed at the very start of the line or right
|
||||||
|
/// after a wrapper whose own semantics accept one there ([`Wrapper::accepts_assignment`]) —
|
||||||
|
/// `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
|
||||||
|
/// skipping to `claude`, since `command`/`nohup` just exec their next argument
|
||||||
|
/// as the program, they don't setenv from an assignment-shaped one (#199).
|
||||||
|
/// `None` when [`tokenize`] can't safely make sense of the line at all.
|
||||||
|
fn resolve(command: &str) -> Option<Resolution> {
|
||||||
|
let words = tokenize(command)?;
|
||||||
|
let refs: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut assignment_allowed = true;
|
||||||
|
loop {
|
||||||
|
if assignment_allowed {
|
||||||
|
while matches!(words.get(i), Some(word) if is_env_assignment(word)) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(word) = words.get(i) else { break };
|
||||||
|
let wrapper = basename(&word.text).to_lowercase();
|
||||||
|
let Some(w) = wrapper_resolver(&wrapper) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
match (w.resolve)(&refs, i) {
|
||||||
|
Some(next) if next > i => {
|
||||||
|
i = next;
|
||||||
|
assignment_allowed = w.accepts_assignment;
|
||||||
|
}
|
||||||
|
// Not a form of the wrapper we recognise (an unknown option, or
|
||||||
|
// — for `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it):
|
||||||
|
// the wrapper word itself is the program, not a launcher.
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Resolution {
|
||||||
|
words: words.into_iter().map(|w| w.text).collect(),
|
||||||
|
program_index: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The naive "first whitespace word, basenamed" reading used only when
|
||||||
|
/// [`tokenize`] rejects the line outright — still something sane to show,
|
||||||
|
/// never a wrapper/tool match to act on.
|
||||||
|
fn legacy_first_word(command: &str) -> String {
|
||||||
|
match command.split_whitespace().next() {
|
||||||
|
Some(word) => basename(word).to_string(),
|
||||||
|
None => command.trim().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One token from [`tokenize`]: its dequoted text, and — when both the name
|
||||||
|
/// and `=` were entirely unquoted/unescaped in the source — the byte length
|
||||||
|
/// of the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
|
||||||
|
/// makes the word an executable named `FOO=1`; quoting/escaping only the value
|
||||||
|
/// preserves the assignment, matching Bash's assignment-word grammar (#199).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct Word {
|
||||||
|
text: String,
|
||||||
|
plain_eq: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
|
||||||
|
/// `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
|
||||||
|
/// `None` the moment it needs real shell grammar this resolver doesn't
|
||||||
|
/// attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
|
||||||
|
/// substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
|
||||||
|
/// separator, like `;` — #199), or an unterminated quote/escape. Never
|
||||||
|
/// expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
|
||||||
|
/// backslash immediately before a newline, quoted or not, is a line
|
||||||
|
/// continuation: removed entirely rather than embedding the newline or
|
||||||
|
/// ending the word (#199).
|
||||||
|
fn tokenize(command: &str) -> Option<Vec<Word>> {
|
||||||
|
let mut words = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut has_current = false;
|
||||||
|
let mut plain_eq: Option<usize> = None;
|
||||||
|
let mut assignment_name_is_plain = true;
|
||||||
|
let mut chars = command.chars().peekable();
|
||||||
|
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
' ' | '\t' | '\r' => {
|
||||||
|
if has_current {
|
||||||
|
words.push(Word {
|
||||||
|
text: std::mem::take(&mut current),
|
||||||
|
plain_eq: plain_eq.take(),
|
||||||
|
});
|
||||||
|
has_current = false;
|
||||||
|
assignment_name_is_plain = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'\n' => return None,
|
||||||
|
'|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' => return None,
|
||||||
|
'$' if chars.peek() == Some(&'(') => return None,
|
||||||
|
'\'' => {
|
||||||
|
has_current = true;
|
||||||
|
if plain_eq.is_none() {
|
||||||
|
assignment_name_is_plain = false;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
match chars.next() {
|
||||||
|
Some('\'') => break,
|
||||||
|
Some(c2) => current.push(c2),
|
||||||
|
None => return None, // unterminated single quote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'"' => {
|
||||||
|
has_current = true;
|
||||||
|
if plain_eq.is_none() {
|
||||||
|
assignment_name_is_plain = false;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
match chars.next() {
|
||||||
|
Some('"') => break,
|
||||||
|
Some('\\') => match chars.next() {
|
||||||
|
Some(c2 @ ('"' | '\\' | '$' | '`')) => current.push(c2),
|
||||||
|
Some('\n') => {} // line continuation, removed entirely
|
||||||
|
Some(c2) => {
|
||||||
|
current.push('\\');
|
||||||
|
current.push(c2);
|
||||||
|
}
|
||||||
|
None => return None, // trailing backslash
|
||||||
|
},
|
||||||
|
Some(c2) => current.push(c2),
|
||||||
|
None => return None, // unterminated double quote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'\\' => match chars.next() {
|
||||||
|
Some('\n') => {} // line continuation, removed entirely
|
||||||
|
Some(c2) => {
|
||||||
|
has_current = true;
|
||||||
|
if plain_eq.is_none() {
|
||||||
|
assignment_name_is_plain = false;
|
||||||
|
}
|
||||||
|
current.push(c2);
|
||||||
|
}
|
||||||
|
None => return None, // trailing backslash
|
||||||
|
},
|
||||||
|
other => {
|
||||||
|
has_current = true;
|
||||||
|
if other == '=' && plain_eq.is_none() && assignment_name_is_plain {
|
||||||
|
plain_eq = Some(current.len());
|
||||||
|
}
|
||||||
|
current.push(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_current {
|
||||||
|
words.push(Word {
|
||||||
|
text: current,
|
||||||
|
plain_eq,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(words)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrapper's known `-`/`--` options: which take no value, and which
|
||||||
|
/// consume either a following word or a `--flag=value` as theirs. A
|
||||||
|
/// `-`-prefixed word this table doesn't know halts resolution rather than
|
||||||
|
/// guessing past it (#199) — callers read that as [`OptionTable::consume`]
|
||||||
|
/// returning `None`.
|
||||||
|
struct OptionTable {
|
||||||
|
flags: &'static [&'static str],
|
||||||
|
valued: &'static [&'static str],
|
||||||
|
/// Whether this wrapper's short options may be combined (`-iv`) or take
|
||||||
|
/// an attached value (`-uroot`), the way real getopt-based tools do
|
||||||
|
/// (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/
|
||||||
|
/// `dlx`) don't get this — guessing at bundling they don't actually
|
||||||
|
/// support would resolve through options they'd reject outright.
|
||||||
|
bundles_short_options: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OptionTable {
|
||||||
|
/// Consume this table's options starting at `words[i]`, returning the
|
||||||
|
/// index just past them (which may equal `i` if there are none) — or
|
||||||
|
/// `None` on an option this table doesn't recognise, or a valued option
|
||||||
|
/// missing its operand.
|
||||||
|
fn consume(&self, words: &[&str], mut i: usize) -> Option<usize> {
|
||||||
|
loop {
|
||||||
|
let Some(&word) = words.get(i) else {
|
||||||
|
return Some(i);
|
||||||
|
};
|
||||||
|
if word == "--" {
|
||||||
|
return Some(i + 1);
|
||||||
|
}
|
||||||
|
if word == "-" || !word.starts_with('-') {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
if let Some((flag, value)) = word.split_once('=') {
|
||||||
|
return if self.valued.contains(&flag) && !value.is_empty() {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if self.flags.contains(&word) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.valued.contains(&word) {
|
||||||
|
let value = words.get(i + 1)?;
|
||||||
|
if value.is_empty() || value.starts_with('-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.bundles_short_options
|
||||||
|
&& !word.starts_with("--")
|
||||||
|
&& word.len() > 2
|
||||||
|
&& self.matches_short_cluster(word)
|
||||||
|
{
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `-iv` (bundled boolean short flags) or `-uroot` (a valued short
|
||||||
|
/// option with its value attached) — getopt's traditional short-option
|
||||||
|
/// forms. `false` the moment a character isn't a short option this
|
||||||
|
/// table knows, or a valued one ends the word with no attached value
|
||||||
|
/// left to be its operand (#199: rejected rather than guessed to come
|
||||||
|
/// from the next word instead).
|
||||||
|
fn matches_short_cluster(&self, word: &str) -> bool {
|
||||||
|
if !word.is_ascii() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let bytes = word.as_bytes();
|
||||||
|
let mut buf = [b'-', 0];
|
||||||
|
let mut idx = 1;
|
||||||
|
while idx < bytes.len() {
|
||||||
|
buf[1] = bytes[idx];
|
||||||
|
let short = std::str::from_utf8(&buf).expect("ascii buffer");
|
||||||
|
if self.flags.contains(&short) {
|
||||||
|
idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.valued.contains(&short) {
|
||||||
|
return idx + 1 < bytes.len();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_OPTIONS: OptionTable = OptionTable {
|
||||||
|
flags: &[],
|
||||||
|
valued: &[],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENV_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &[
|
||||||
|
"-i",
|
||||||
|
"--ignore-environment",
|
||||||
|
"-0",
|
||||||
|
"--null",
|
||||||
|
"-v",
|
||||||
|
"--debug",
|
||||||
|
],
|
||||||
|
valued: &["-u", "--unset", "-C", "--chdir"],
|
||||||
|
bundles_short_options: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUDO_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &[
|
||||||
|
"-i",
|
||||||
|
"--login",
|
||||||
|
"-n",
|
||||||
|
"--non-interactive",
|
||||||
|
"-E",
|
||||||
|
"--preserve-env",
|
||||||
|
"-H",
|
||||||
|
"--set-home",
|
||||||
|
"-A",
|
||||||
|
"--askpass",
|
||||||
|
"-b",
|
||||||
|
"--background",
|
||||||
|
"-k",
|
||||||
|
"--reset-timestamp",
|
||||||
|
"-K",
|
||||||
|
"--remove-timestamp",
|
||||||
|
"-S",
|
||||||
|
"--stdin",
|
||||||
|
],
|
||||||
|
valued: &["-u", "--user", "-g", "--group"],
|
||||||
|
bundles_short_options: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `-v`/`-V` only print what would run — they never execute it — so
|
||||||
|
/// resolving through them would claim a program runs that `command` never
|
||||||
|
/// launches (#199). Only `-p`, which still executes normally, is safe to
|
||||||
|
/// look through.
|
||||||
|
const COMMAND_BUILTIN_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["-p"],
|
||||||
|
valued: &[],
|
||||||
|
bundles_short_options: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The bash *keyword* `time` only ever parses `-p` (POSIX output format);
|
||||||
|
/// `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
|
||||||
|
/// `/usr/bin/time`, a different program a bare `time` word doesn't
|
||||||
|
/// necessarily name. Assuming them here would be guessing which `time`
|
||||||
|
/// resolves (#199).
|
||||||
|
fn resolve_time(words: &[&str], i: usize) -> Option<usize> {
|
||||||
|
let mut next = i + 1;
|
||||||
|
if words.get(next) == Some(&"-p") {
|
||||||
|
next += 1;
|
||||||
|
}
|
||||||
|
if words.get(next) == Some(&"--") {
|
||||||
|
next += 1;
|
||||||
|
}
|
||||||
|
match words.get(next) {
|
||||||
|
Some(word) if word.starts_with('-') => None,
|
||||||
|
_ => Some(next),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `-c`/`--call` embeds a whole shell command string rather than naming the
|
||||||
|
/// program positionally — resolving past it would mean guessing at a
|
||||||
|
/// program buried inside that string, so it's left unrecognised and
|
||||||
|
/// resolution stops at `npx` (#199).
|
||||||
|
const NPX_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"],
|
||||||
|
valued: &["-p", "--package", "--shell"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PNPM_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &[
|
||||||
|
"--silent",
|
||||||
|
"-s",
|
||||||
|
"--stream",
|
||||||
|
"--aggregate-output",
|
||||||
|
"--no-progress",
|
||||||
|
],
|
||||||
|
// `--package`/`--package=value` is documented in this pre-`dlx`
|
||||||
|
// position (`pnpm --package=x dlx y`), alongside the identical flag
|
||||||
|
// `dlx` itself also accepts (`PNPM_DLX_OPTS`).
|
||||||
|
valued: &["-C", "--dir", "--filter", "--package"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const YARN_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["--silent", "-s", "--no-progress", "--non-interactive"],
|
||||||
|
valued: &["--cwd"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PNPM_DLX_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["-s", "--silent"],
|
||||||
|
valued: &["--package"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const YARN_DLX_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["-q", "--quiet"],
|
||||||
|
valued: &["-p", "--package"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUN_X_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["--bun", "--no-install", "--verbose", "--silent"],
|
||||||
|
valued: &["-p", "--package"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A resolver for one wrapper form: given the tokenized command and the
|
||||||
|
/// index of the wrapper word itself, the index of the word after it (past
|
||||||
|
/// its options and any subcommand it requires), or `None` if this occurrence
|
||||||
|
/// isn't a form we resolve through.
|
||||||
|
type WrapperResolver = fn(&[&str], usize) -> Option<usize>;
|
||||||
|
|
||||||
|
/// One recognised wrapper form: how to walk past it, and whether an
|
||||||
|
/// assignment word is a valid follow-on once it's been walked past.
|
||||||
|
struct Wrapper {
|
||||||
|
resolve: WrapperResolver,
|
||||||
|
/// `true` only for wrappers that themselves interpret a following
|
||||||
|
/// `FOO=bar` as an environment assignment for the command they launch
|
||||||
|
/// (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
|
||||||
|
/// grammar for a brand new simple command (`time`). Every other wrapper
|
||||||
|
/// here is a plain executable that execs its next argument as the
|
||||||
|
/// program outright — an assignment-shaped one names a program literally
|
||||||
|
/// called `FOO=1`, not an env var (#199).
|
||||||
|
accepts_assignment: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrapper_resolver(name: &str) -> Option<Wrapper> {
|
||||||
|
let (resolve, accepts_assignment): (WrapperResolver, bool) = match name {
|
||||||
|
"env" => (|words, i| ENV_OPTS.consume(words, i + 1), true),
|
||||||
|
"sudo" => (|words, i| SUDO_OPTS.consume(words, i + 1), true),
|
||||||
|
"command" => (|words, i| COMMAND_BUILTIN_OPTS.consume(words, i + 1), false),
|
||||||
|
"nohup" => (|words, i| NO_OPTIONS.consume(words, i + 1), false),
|
||||||
|
"time" => (resolve_time, true),
|
||||||
|
"npx" => (|words, i| NPX_OPTS.consume(words, i + 1), false),
|
||||||
|
"pnpm" => (
|
||||||
|
|words, i| resolve_dlx(&PNPM_OPTS, &PNPM_DLX_OPTS, words, i),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
"yarn" => (
|
||||||
|
|words, i| resolve_dlx(&YARN_OPTS, &YARN_DLX_OPTS, words, i),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
"bun" => (resolve_bun, false),
|
||||||
|
"bunx" => (|words, i| BUN_X_OPTS.consume(words, i + 1), false),
|
||||||
|
"uvx" => (|_words, i| Some(i + 1), false),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(Wrapper {
|
||||||
|
resolve,
|
||||||
|
accepts_assignment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
|
||||||
|
/// — without it, the next word is a `package.json` script name, not a
|
||||||
|
/// package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
|
||||||
|
/// itself takes launcher-specific options too, consumed before the executable.
|
||||||
|
fn resolve_dlx(
|
||||||
|
opts: &OptionTable,
|
||||||
|
dlx_opts: &OptionTable,
|
||||||
|
words: &[&str],
|
||||||
|
i: usize,
|
||||||
|
) -> Option<usize> {
|
||||||
|
let j = opts.consume(words, i + 1)?;
|
||||||
|
if words.get(j) != Some(&"dlx") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
dlx_opts.consume(words, j + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
|
||||||
|
/// (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
|
||||||
|
/// subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
|
||||||
|
/// the executable.
|
||||||
|
fn resolve_bun(words: &[&str], i: usize) -> Option<usize> {
|
||||||
|
if words.get(i + 1) != Some(&"x") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
BUN_X_OPTS.consume(words, i + 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `FOO=bar`, the shell's own prefix for one command's environment —
|
||||||
|
/// recognised only when the `=` came from plain, unquoted/unescaped source
|
||||||
|
/// text (#199): `"FOO=1"` and `FOO\=1` name a program called `FOO=1`, not
|
||||||
|
/// an assignment.
|
||||||
|
fn is_env_assignment(word: &Word) -> bool {
|
||||||
|
let Some(eq) = word.plain_eq else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let name = &word.text[..eq];
|
||||||
|
let mut chars = name.chars();
|
||||||
match chars.next() {
|
match chars.next() {
|
||||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||||
_ => return false,
|
_ => return false,
|
||||||
}
|
}
|
||||||
let rest: String = chars.collect();
|
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
match rest.split_once('=') {
|
|
||||||
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn basename(word: &str) -> &str {
|
fn basename(word: &str) -> &str {
|
||||||
@@ -286,6 +744,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,38 +957,132 @@ mod tests {
|
|||||||
"claude"
|
"claude"
|
||||||
);
|
);
|
||||||
assert_eq!(command_program(" zsh -l "), "zsh");
|
assert_eq!(command_program(" zsh -l "), "zsh");
|
||||||
assert_eq!(command_program("hx src/main.rs"), "hx");
|
}
|
||||||
|
|
||||||
|
/// The #199 resolver contract: every case in the shared parity fixture,
|
||||||
|
/// asserted identically here and by `web/src/toolIcon.test.ts`, so a
|
||||||
|
/// resolver regression in either language fails its own suite.
|
||||||
|
#[test]
|
||||||
|
fn resolves_the_shared_command_resolution_fixture() {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Case {
|
||||||
|
command: String,
|
||||||
|
program: String,
|
||||||
|
tool: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Fixture {
|
||||||
|
cases: Vec<Case>,
|
||||||
|
}
|
||||||
|
let raw = include_str!("../../../fixtures/command-resolution.json");
|
||||||
|
let fixture: Fixture = serde_json::from_str(raw).expect("valid fixture JSON");
|
||||||
|
assert!(!fixture.cases.is_empty());
|
||||||
|
for case in &fixture.cases {
|
||||||
|
assert_eq!(
|
||||||
|
command_program(&case.command),
|
||||||
|
case.program,
|
||||||
|
"program for {:?}",
|
||||||
|
case.command
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
command_tool(&case.command),
|
||||||
|
case.tool.as_deref(),
|
||||||
|
"tool for {:?}",
|
||||||
|
case.command
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn word(text: &str) -> Word {
|
||||||
|
Word {
|
||||||
|
text: text.into(),
|
||||||
|
plain_eq: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell-aware tokenization (#199): quoting/escaping is honoured, never
|
||||||
|
/// evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion is attempted.
|
||||||
|
#[test]
|
||||||
|
fn tokenize_handles_quotes_and_escapes_without_expanding_anything() {
|
||||||
|
assert_eq!(
|
||||||
|
tokenize("claude --resume"),
|
||||||
|
Some(vec![word("claude"), word("--resume")])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokenize("\"/opt/my agent/claude\" --resume"),
|
||||||
|
Some(vec![word("/opt/my agent/claude"), word("--resume")])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokenize("FOO=\"bar baz\" claude"),
|
||||||
|
Some(vec![
|
||||||
|
Word {
|
||||||
|
text: "FOO=bar baz".into(),
|
||||||
|
plain_eq: Some(3),
|
||||||
|
},
|
||||||
|
word("claude"),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokenize("claude --name=$HOME"),
|
||||||
|
Some(vec![
|
||||||
|
word("claude"),
|
||||||
|
Word {
|
||||||
|
text: "--name=$HOME".into(),
|
||||||
|
plain_eq: Some(6),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
"a bare $ is kept literal, never expanded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real shell grammar this resolver doesn't attempt — pipelines, list
|
||||||
|
/// operators, subshells/substitution, an unescaped newline, an
|
||||||
|
/// unterminated quote — is rejected outright rather than guessed
|
||||||
|
/// through (#199).
|
||||||
|
#[test]
|
||||||
|
fn tokenize_rejects_ambiguous_shell_grammar() {
|
||||||
|
for command in [
|
||||||
|
"claude | tee log.txt",
|
||||||
|
"claude; echo done",
|
||||||
|
"claude && echo done",
|
||||||
|
"echo hi $(claude)",
|
||||||
|
"claude \"unterminated",
|
||||||
|
"claude 'unterminated",
|
||||||
|
"claude \\",
|
||||||
|
"claude\nclaude-code", // an unescaped newline separates commands, like `;`
|
||||||
|
] {
|
||||||
|
assert_eq!(tokenize(command), None, "{command:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A backslash immediately before a newline is a line continuation:
|
||||||
|
/// removed entirely, joining what it split rather than embedding a
|
||||||
|
/// literal newline or ending the command (#199).
|
||||||
|
#[test]
|
||||||
|
fn tokenize_treats_backslash_newline_as_a_continuation() {
|
||||||
|
assert_eq!(
|
||||||
|
tokenize("clau\\\nde --resume"),
|
||||||
|
Some(vec![word("claude"), word("--resume")])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only a plain, unquoted/unescaped `=` marks an assignment boundary —
|
||||||
|
/// quoting or escaping the `=` itself disqualifies the word, even
|
||||||
|
/// though the dequoted text looks identical (#199).
|
||||||
|
#[test]
|
||||||
|
fn tokenize_only_marks_plain_unquoted_equals_signs_as_assignments() {
|
||||||
|
assert_eq!(tokenize("FOO=1 claude").unwrap()[0].plain_eq, Some(3));
|
||||||
|
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
|
||||||
|
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn command_program_looks_through_paths_env_and_wrappers() {
|
fn structured_view_support_follows_the_resolved_program() {
|
||||||
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
|
assert!(supports_structured_view("claude --resume"));
|
||||||
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
|
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
|
||||||
assert_eq!(command_program("npx opencode"), "opencode");
|
assert!(supports_structured_view("npx claude-code"));
|
||||||
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
|
assert!(!supports_structured_view("opencode"));
|
||||||
}
|
assert!(!supports_structured_view("bash -l"));
|
||||||
|
assert!(!supports_structured_view("claude-code-acp"));
|
||||||
#[test]
|
|
||||||
fn command_program_keeps_a_line_that_is_only_wrappers() {
|
|
||||||
assert_eq!(command_program("sudo"), "sudo");
|
|
||||||
assert_eq!(command_program(""), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
|
|
||||||
/// tools the web's `splitCommand().tool` does.
|
|
||||||
#[test]
|
|
||||||
fn command_tool_names_the_mark_the_card_shows() {
|
|
||||||
assert_eq!(command_tool("claude --resume"), Some("claude"));
|
|
||||||
assert_eq!(command_tool("npx opencode"), Some("opencode"));
|
|
||||||
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
|
|
||||||
assert_eq!(command_tool("CLAUDE"), Some("claude"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Narrow on purpose: an unrecognised program has no mark, so the
|
|
||||||
/// candidate has no `tool` field to match rather than a wrong one.
|
|
||||||
#[test]
|
|
||||||
fn command_tool_is_absent_for_an_unrecognised_program() {
|
|
||||||
assert_eq!(command_tool("hx src/main.rs"), None);
|
|
||||||
assert_eq!(command_tool(""), None);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ pub struct Session {
|
|||||||
/// daemon that predates the field keeps working untouched.
|
/// daemon that predates the field keeps working untouched.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub view: SessionView,
|
pub view: SessionView,
|
||||||
/// The ACP session id the `claude-code-acp` adapter handed back from
|
/// The ACP session id the selected provider adapter handed back from
|
||||||
/// `session/new` (#134). Only a [`SessionView::Structured`] session has
|
/// `session/new` (#134). Only a [`SessionView::Structured`] session has
|
||||||
/// one, and only once its handshake succeeded; a later respawn passes it
|
/// one, and only once its handshake succeeded; a later respawn passes it
|
||||||
/// to `session/load` so the conversation continues (#135). Distinct from
|
/// to `session/load` so the conversation continues (#135). Distinct from
|
||||||
@@ -132,6 +132,16 @@ pub struct Session {
|
|||||||
/// the two are minted by different processes with different lifetimes.
|
/// the two are minted by different processes with different lifetimes.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub acp_session_id: Option<String>,
|
pub acp_session_id: Option<String>,
|
||||||
|
/// When `true` the daemon auto-approves every ACP permission request for
|
||||||
|
/// this session instead of surfacing them to the client. Set at spawn time
|
||||||
|
/// from the preset or the `CreateSessionRequest`; persisted so it survives
|
||||||
|
/// a daemon restart.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_approve: bool,
|
||||||
|
/// Prompts queued on the daemon while a turn was in flight or the adapter
|
||||||
|
/// was still booting. Persisted so the queue survives a daemon restart.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub queued_prompts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A session's process shape (#133): a plain terminal, or a structured agent
|
/// A session's process shape (#133): a plain terminal, or a structured agent
|
||||||
@@ -156,9 +166,9 @@ pub enum SessionStatus {
|
|||||||
Dead,
|
Dead,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
/// Session activity, orthogonal to [`SessionStatus`] (NAP-340). Terminal
|
||||||
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
|
/// sessions use pane/process inference and agent reports; structured sessions
|
||||||
/// inspection — never by parsing the terminal byte stream.
|
/// use their ACP turn lifecycle (docs/SIDEBAR.md).
|
||||||
///
|
///
|
||||||
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
||||||
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
||||||
@@ -166,16 +176,19 @@ pub enum SessionStatus {
|
|||||||
/// forgiven.
|
/// forgiven.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
||||||
pub enum ActivityState {
|
pub enum ActivityState {
|
||||||
/// Producing output, or the foreground process is busy.
|
/// Producing output, the foreground process is busy, or an ACP turn is active.
|
||||||
Active,
|
Active,
|
||||||
/// Foreground process alive but quiescent. The forward-compatible default.
|
/// Foreground process alive but quiescent. The forward-compatible default.
|
||||||
#[default]
|
#[default]
|
||||||
Idle,
|
Idle,
|
||||||
/// Blocked on a `read()` from the pty — waiting on the user.
|
/// Blocked on terminal input or waiting for an ACP approval.
|
||||||
AwaitingInput,
|
AwaitingInput,
|
||||||
/// The agent running in the session reported a failure (#102). Never
|
/// An agent reported a failure, or a structured session/turn failed.
|
||||||
/// inferred by the daemon; only set when an agent reports it (#105).
|
|
||||||
Error,
|
Error,
|
||||||
|
/// The ACP adapter is booting; the session is registered but cannot accept
|
||||||
|
/// prompts yet. Older clients that do not know this variant degrade it to
|
||||||
|
/// `Idle` via the lenient deserializer — acceptable.
|
||||||
|
Starting,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for ActivityState {
|
impl<'de> Deserialize<'de> for ActivityState {
|
||||||
@@ -188,6 +201,7 @@ impl<'de> Deserialize<'de> for ActivityState {
|
|||||||
"Active" => Self::Active,
|
"Active" => Self::Active,
|
||||||
"AwaitingInput" => Self::AwaitingInput,
|
"AwaitingInput" => Self::AwaitingInput,
|
||||||
"Error" => Self::Error,
|
"Error" => Self::Error,
|
||||||
|
"Starting" => Self::Starting,
|
||||||
// Known or not, any other spelling degrades to the neutral default
|
// Known or not, any other spelling degrades to the neutral default
|
||||||
_ => Self::Idle,
|
_ => Self::Idle,
|
||||||
})
|
})
|
||||||
@@ -226,9 +240,10 @@ where
|
|||||||
"Idle" => Ok(ActivityState::Idle),
|
"Idle" => Ok(ActivityState::Idle),
|
||||||
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
||||||
"Error" => Ok(ActivityState::Error),
|
"Error" => Ok(ActivityState::Error),
|
||||||
|
"Starting" => Ok(ActivityState::Starting),
|
||||||
other => Err(D::Error::unknown_variant(
|
other => Err(D::Error::unknown_variant(
|
||||||
other,
|
other,
|
||||||
&["Active", "Idle", "AwaitingInput", "Error"],
|
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,6 +399,10 @@ pub struct CreateSessionRequest {
|
|||||||
/// untouched.
|
/// untouched.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub view: SessionView,
|
pub view: SessionView,
|
||||||
|
/// When `true`, the daemon auto-approves every ACP permission request
|
||||||
|
/// for this structured session.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_approve: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
||||||
@@ -523,6 +542,11 @@ pub struct SpawnPreset {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub command: String,
|
pub command: String,
|
||||||
|
/// When `true`, the daemon auto-approves every ACP permission request
|
||||||
|
/// for structured sessions spawned from this preset instead of surfacing
|
||||||
|
/// them to the client.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_approve: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build provenance of the running daemon: the commit it was compiled
|
/// Build provenance of the running daemon: the commit it was compiled
|
||||||
@@ -755,6 +779,154 @@ pub enum CatalogEvent {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Structured-session projections
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One client-neutral item in a structured session transcript.
|
||||||
|
///
|
||||||
|
/// `data` preserves the ACP payload without committing clients to an ACP
|
||||||
|
/// dependency. Rendering choices, including markdown and tool-card treatment,
|
||||||
|
/// stay with the client.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct TranscriptRow {
|
||||||
|
/// Stable within one session, so later deltas can address this row.
|
||||||
|
pub id: String,
|
||||||
|
pub kind: TranscriptRowKind,
|
||||||
|
pub data: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The semantic shape of a [`TranscriptRow`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TranscriptRowKind {
|
||||||
|
User,
|
||||||
|
AgentText,
|
||||||
|
AgentThought,
|
||||||
|
ToolCall,
|
||||||
|
ToolDiff,
|
||||||
|
Approval,
|
||||||
|
Plan,
|
||||||
|
Notice,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete transcript at a durable event-log position.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct TranscriptSnapshot {
|
||||||
|
pub cursor: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rows: Vec<TranscriptRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One durable, client-neutral replay step from a structured session.
|
||||||
|
///
|
||||||
|
/// Results are ordered by increasing `cursor`. `since` returns the first
|
||||||
|
/// matching steps; `before` returns the final matching steps, still increasing.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct StructuredReplayEvent {
|
||||||
|
/// Durable ACP-log position represented by this projected step.
|
||||||
|
pub cursor: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub transcript: Option<TranscriptDelta>,
|
||||||
|
pub state: ReducedState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A change to a transcript row.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum TranscriptDelta {
|
||||||
|
Append { row: TranscriptRow },
|
||||||
|
Patch { row: TranscriptRow },
|
||||||
|
Remove { id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-turn indicators derived from the ACP stream.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TurnState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub responding: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub thinking: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active_tool_calls: Vec<String>,
|
||||||
|
/// Wall-clock start of the active prompt. This lets reconnecting clients
|
||||||
|
/// keep one continuous elapsed-time display instead of restarting at zero.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub started_at: Option<DateTime<Utc>>,
|
||||||
|
/// Context-window usage when the active prompt began. ACP exposes context
|
||||||
|
/// occupancy, not provider billing-token accounting, so clients must label
|
||||||
|
/// deltas as context tokens rather than tokens billed.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub started_context_tokens: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A whole-session view derived from ACP notifications.
|
||||||
|
///
|
||||||
|
/// Values whose shape ACP owns are carried as JSON so this wire protocol stays
|
||||||
|
/// forward-compatible when ACP adds fields or variants.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ReducedState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub turn: TurnState,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pending_approval: Option<Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub plan: Option<Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage: Option<Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context: Option<Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mode: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub config_options: Option<Value>,
|
||||||
|
/// Prompts queued on the daemon while a turn is in flight. Drained as
|
||||||
|
/// a single concatenated prompt when the current turn completes.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub queued_prompts: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server messages sent over a structured-session WebSocket.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum StructuredEvent {
|
||||||
|
TranscriptSnapshot {
|
||||||
|
snapshot: TranscriptSnapshot,
|
||||||
|
},
|
||||||
|
ReducedState {
|
||||||
|
state: ReducedState,
|
||||||
|
},
|
||||||
|
ProjectionUpdate {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
transcript: Option<TranscriptDelta>,
|
||||||
|
state: ReducedState,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP body for `POST /api/sessions/:id/structured/prompt`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct StructuredPromptRequest {
|
||||||
|
pub prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP body for `POST /api/sessions/:id/structured/config`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct StructuredConfigRequest {
|
||||||
|
pub config_id: String,
|
||||||
|
pub value: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Operator decision for a pending ACP permission request.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum StructuredApprovalDecision {
|
||||||
|
AllowOnce,
|
||||||
|
AllowForSession,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Client ↔ Daemon control messages (text frames on the attach WS)
|
// Client ↔ Daemon control messages (text frames on the attach WS)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -896,6 +1068,8 @@ mod tests {
|
|||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
view: SessionView::Terminal,
|
view: SessionView::Terminal,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,6 +1282,7 @@ mod tests {
|
|||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
view: SessionView::Terminal,
|
view: SessionView::Terminal,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
assert_eq!(req, round_trip(&req));
|
assert_eq!(req, round_trip(&req));
|
||||||
}
|
}
|
||||||
@@ -1144,6 +1319,31 @@ mod tests {
|
|||||||
assert_eq!(structured, round_trip(&structured));
|
assert_eq!(structured, round_trip(&structured));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_projection_types_round_trip_and_default() {
|
||||||
|
let row = TranscriptRow {
|
||||||
|
id: "tool:read-1".into(),
|
||||||
|
kind: TranscriptRowKind::ToolCall,
|
||||||
|
data: serde_json::json!({"name": "vendor.read", "title": "Read"}),
|
||||||
|
};
|
||||||
|
let snapshot = TranscriptSnapshot {
|
||||||
|
cursor: 7,
|
||||||
|
rows: vec![row.clone()],
|
||||||
|
};
|
||||||
|
assert_eq!(snapshot, round_trip(&snapshot));
|
||||||
|
assert_eq!(
|
||||||
|
TranscriptDelta::Patch { row: row.clone() },
|
||||||
|
round_trip(&TranscriptDelta::Patch { row })
|
||||||
|
);
|
||||||
|
|
||||||
|
let state: ReducedState = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||||
|
assert_eq!(state, ReducedState::default());
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(TranscriptRowKind::AgentThought).unwrap(),
|
||||||
|
serde_json::json!("agent_thought")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn acp_session_id_defaults_to_none_and_round_trips() {
|
fn acp_session_id_defaults_to_none_and_round_trips() {
|
||||||
// A payload from before #134 carries no `acp_session_id`; it must
|
// A payload from before #134 carries no `acp_session_id`; it must
|
||||||
|
|||||||
@@ -71,6 +71,41 @@ pub fn attach(id: Uuid) -> String {
|
|||||||
format!("{API_PREFIX}/attach/{id}")
|
format!("{API_PREFIX}/attach/{id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `WS /api/structured/:id` — transcript/state snapshots and live deltas.
|
||||||
|
pub fn structured(id: Uuid) -> String {
|
||||||
|
format!("{API_PREFIX}/structured/{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/sessions/:id/structured/replay` — cursor-bounded ACP history.
|
||||||
|
pub fn structured_replay(id: Uuid) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/structured/replay")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/structured/prompt` — send an ACP prompt.
|
||||||
|
pub fn structured_prompt(id: Uuid) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/structured/prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
|
||||||
|
pub fn structured_attachment(id: Uuid) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/structured/attachments")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
|
||||||
|
pub fn structured_cancel(id: Uuid) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/structured/cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/structured/config` — change an ACP session option.
|
||||||
|
pub fn structured_config(id: Uuid) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/structured/config")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/structured/approvals/:nonce` — answer ACP permission.
|
||||||
|
pub fn structured_approval(id: Uuid, nonce: &str) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Axum-style patterns for the two parameterised routes, so the server
|
/// Axum-style patterns for the two parameterised routes, so the server
|
||||||
/// registers the same paths [`session`] and [`attach`] build.
|
/// registers the same paths [`session`] and [`attach`] build.
|
||||||
pub mod patterns {
|
pub mod patterns {
|
||||||
@@ -80,6 +115,13 @@ pub mod patterns {
|
|||||||
pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt";
|
pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt";
|
||||||
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
|
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
|
||||||
pub const ATTACH: &str = "/api/attach/:id";
|
pub const ATTACH: &str = "/api/attach/:id";
|
||||||
|
pub const STRUCTURED: &str = "/api/structured/:id";
|
||||||
|
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
||||||
|
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
||||||
|
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
|
||||||
|
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
||||||
|
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
||||||
|
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A daemon base URL that could not be turned into a WebSocket base.
|
/// A daemon base URL that could not be turned into a WebSocket base.
|
||||||
@@ -129,6 +171,11 @@ pub fn attach_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
|
|||||||
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
|
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full `WS /api/structured/:id` URL for a daemon reachable at `http_base`.
|
||||||
|
pub fn structured_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
|
||||||
|
Ok(format!("{}{}", ws_base(http_base)?, structured(id)))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -178,6 +225,10 @@ mod tests {
|
|||||||
events_url("http://host:7070/").unwrap(),
|
events_url("http://host:7070/").unwrap(),
|
||||||
"ws://host:7070/api/events"
|
"ws://host:7070/api/events"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
structured_url("http://host:7070/", id()).unwrap(),
|
||||||
|
"ws://host:7070/api/structured/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -206,6 +257,22 @@ mod tests {
|
|||||||
attach(id()),
|
attach(id()),
|
||||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
structured(id()),
|
||||||
|
"/api/structured/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
structured_replay(id()),
|
||||||
|
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/replay"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
structured_prompt(id()),
|
||||||
|
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/prompt"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
structured_cancel(id()),
|
||||||
|
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/cancel"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
||||||
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
@@ -240,6 +307,22 @@ mod tests {
|
|||||||
patterns::ATTACH.trim_end_matches(":id"),
|
patterns::ATTACH.trim_end_matches(":id"),
|
||||||
attach(id()).trim_end_matches(&id().to_string())
|
attach(id()).trim_end_matches(&id().to_string())
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
patterns::STRUCTURED.trim_end_matches(":id"),
|
||||||
|
structured(id()).trim_end_matches(&id().to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
patterns::STRUCTURED_REPLAY.trim_end_matches(":id/structured/replay"),
|
||||||
|
session(id()).trim_end_matches(&id().to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
patterns::STRUCTURED_PROMPT.trim_end_matches(":id/structured/prompt"),
|
||||||
|
session(id()).trim_end_matches(&id().to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
patterns::STRUCTURED_CANCEL.trim_end_matches(":id/structured/cancel"),
|
||||||
|
session(id()).trim_end_matches(&id().to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -258,5 +341,9 @@ mod tests {
|
|||||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||||
}
|
}
|
||||||
assert!(attach(id()).starts_with(API_PREFIX));
|
assert!(attach(id()).starts_with(API_PREFIX));
|
||||||
|
assert!(structured(id()).starts_with(API_PREFIX));
|
||||||
|
assert!(structured_replay(id()).starts_with(API_PREFIX));
|
||||||
|
assert!(structured_prompt(id()).starts_with(API_PREFIX));
|
||||||
|
assert!(structured_cancel(id()).starts_with(API_PREFIX));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ use chrono::Utc;
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
|
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::{broadcast, oneshot};
|
use tokio::sync::{broadcast, oneshot};
|
||||||
@@ -49,6 +50,8 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +63,8 @@ struct Inner {
|
|||||||
forced_error: Option<(u16, ApiError)>,
|
forced_error: Option<(u16, ApiError)>,
|
||||||
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||||
inputs: Vec<(Uuid, SendInputRequest)>,
|
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||||
|
/// Every ACP prompt sent to a structured session, in order.
|
||||||
|
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -92,6 +97,7 @@ impl MockDaemon {
|
|||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||||
|
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
@@ -178,6 +184,16 @@ impl MockDaemon {
|
|||||||
self.state.inner.lock().expect("poisoned").inputs.clone()
|
self.state.inner.lock().expect("poisoned").inputs.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every ACP prompt this daemon received, oldest first.
|
||||||
|
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
|
||||||
|
self.state
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("poisoned")
|
||||||
|
.structured_prompts
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn session_count(&self) -> usize {
|
pub fn session_count(&self) -> usize {
|
||||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||||
}
|
}
|
||||||
@@ -266,6 +282,26 @@ async fn send_input(
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn structured_prompt(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<StructuredPromptRequest>,
|
||||||
|
) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let mut guard = s.inner.lock().expect("poisoned");
|
||||||
|
if !guard.sessions.iter().any(|x| x.id == id) {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
guard.structured_prompts.push((id, req));
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn daemon_info(State(s): State<AppState>) -> Response {
|
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||||
if let Some(r) = forced(&s) {
|
if let Some(r) = forced(&s) {
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ thiserror.workspace = true
|
|||||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
unicode-width.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
vt100.workspace = true
|
vt100.workspace = true
|
||||||
|
|
||||||
|
|||||||
+500
-13
@@ -11,13 +11,17 @@
|
|||||||
//! `dispatch` / the overlay routing.
|
//! `dispatch` / the overlay routing.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crossterm::event::{Event as CtEvent, EventStream};
|
use crossterm::event::{Event as CtEvent, EventStream};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
use maestro_protocol::{
|
||||||
|
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, SessionView,
|
||||||
|
StructuredApprovalDecision,
|
||||||
|
};
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -38,6 +42,8 @@ use crate::render::{draw, RegionMap, RenderInputs};
|
|||||||
use crate::session_task::{SessionInput, SessionTask};
|
use crate::session_task::{SessionInput, SessionTask};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||||
|
use crate::structured_pane::StructuredPane;
|
||||||
|
use crate::structured_task::{StructuredHandle, StructuredTask};
|
||||||
use crate::text_input::TextInput;
|
use crate::text_input::TextInput;
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
@@ -49,11 +55,42 @@ enum OverlayDecision {
|
|||||||
Pass,
|
Pass,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
|
||||||
|
let encoded = base64_encode(text.as_bytes());
|
||||||
|
let mut output = std::io::stdout();
|
||||||
|
write!(output, "\x1b]52;c;{encoded}\x07")?;
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64_encode(bytes: &[u8]) -> String {
|
||||||
|
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||||
|
for chunk in bytes.chunks(3) {
|
||||||
|
let bits = (u32::from(chunk[0]) << 16)
|
||||||
|
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
||||||
|
| u32::from(*chunk.get(2).unwrap_or(&0));
|
||||||
|
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
|
||||||
|
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
|
||||||
|
output.push(if chunk.len() > 1 {
|
||||||
|
TABLE[((bits >> 6) & 0x3f) as usize] as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
output.push(if chunk.len() > 2 {
|
||||||
|
TABLE[(bits & 0x3f) as usize] as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
cfg: TuiConfig,
|
cfg: TuiConfig,
|
||||||
home_dir: String,
|
home_dir: String,
|
||||||
catalog: CatalogStore,
|
catalog: CatalogStore,
|
||||||
emulators: SessionEmulators,
|
emulators: SessionEmulators,
|
||||||
|
structured: HashMap<Uuid, StructuredHandle>,
|
||||||
state: AppState,
|
state: AppState,
|
||||||
help: HelpOverlay,
|
help: HelpOverlay,
|
||||||
chord: ChordState,
|
chord: ChordState,
|
||||||
@@ -163,6 +200,18 @@ pub enum Effect {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
StructuredPrompt {
|
||||||
|
id: Uuid,
|
||||||
|
prompt: String,
|
||||||
|
},
|
||||||
|
StructuredCancel {
|
||||||
|
id: Uuid,
|
||||||
|
},
|
||||||
|
StructuredApproval {
|
||||||
|
id: Uuid,
|
||||||
|
nonce: String,
|
||||||
|
decision: StructuredApprovalDecision,
|
||||||
|
},
|
||||||
/// Fetch presets / projects / names for a freshly opened form (#64).
|
/// Fetch presets / projects / names for a freshly opened form (#64).
|
||||||
LoadDraft,
|
LoadDraft,
|
||||||
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
||||||
@@ -214,6 +263,7 @@ impl App {
|
|||||||
home_dir: std::env::var("HOME").unwrap_or_default(),
|
home_dir: std::env::var("HOME").unwrap_or_default(),
|
||||||
catalog,
|
catalog,
|
||||||
emulators: SessionEmulators::default(),
|
emulators: SessionEmulators::default(),
|
||||||
|
structured: HashMap::new(),
|
||||||
state: AppState::new(),
|
state: AppState::new(),
|
||||||
draft: None,
|
draft: None,
|
||||||
help,
|
help,
|
||||||
@@ -321,6 +371,15 @@ impl App {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.handle_structured_mouse(&key) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(effects) = self.handle_structured_key(&key) {
|
||||||
|
self.perform(effects).await;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(effect) = self.handle_pane_scroll(&key) {
|
if let Some(effect) = self.handle_pane_scroll(&key) {
|
||||||
self.perform(effect.into_iter().collect()).await;
|
self.perform(effect.into_iter().collect()).await;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -399,6 +458,61 @@ impl App {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transcript blocks are pointer targets: click selects the whole block,
|
||||||
|
/// gives it the selection surface, and copies its complete text.
|
||||||
|
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
|
||||||
|
let InputKey::Mouse {
|
||||||
|
kind,
|
||||||
|
region: Region::Pane,
|
||||||
|
row,
|
||||||
|
..
|
||||||
|
} = key
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(sid) = self.state.active_session() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(handle) = self.structured.get(&sid) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(pane) = self.region.pane else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let offset = usize::from(row.saturating_sub(pane.y));
|
||||||
|
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
|
||||||
|
if offset >= usize::from(transcript_height) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut state = handle.state.lock().expect("structured state mutex");
|
||||||
|
let rendered_line = state.scroll.saturating_add(offset);
|
||||||
|
if !state.select_rendered_line(rendered_line) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(id) = state.selected.as_deref() else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if *kind == MouseKind::Hover {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let text = crate::structured_pane::copy_text(transcript);
|
||||||
|
drop(state);
|
||||||
|
if text.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match copy_to_terminal_clipboard(&text) {
|
||||||
|
Ok(()) => self.state.toast("copied transcript block"),
|
||||||
|
Err(error) => self.state.toast(format!("copy failed: {error}")),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||||
/// the daemon.
|
/// the daemon.
|
||||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||||
@@ -419,6 +533,44 @@ impl App {
|
|||||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Effect::StructuredPrompt { id, prompt } => {
|
||||||
|
if let Err(error) = self.daemon.send_structured_prompt(id, &prompt).await {
|
||||||
|
if let Some(handle) = self.structured.get(&id) {
|
||||||
|
handle
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("structured state mutex")
|
||||||
|
.prompt_failed(prompt, error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effect::StructuredCancel { id } => {
|
||||||
|
if let Err(error) = self.daemon.cancel_structured_turn(id).await {
|
||||||
|
if let Some(handle) = self.structured.get(&id) {
|
||||||
|
handle.state.lock().expect("structured state mutex").error =
|
||||||
|
Some(error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effect::StructuredApproval {
|
||||||
|
id,
|
||||||
|
nonce,
|
||||||
|
decision,
|
||||||
|
} => {
|
||||||
|
if let Err(error) = self
|
||||||
|
.daemon
|
||||||
|
.resolve_structured_approval(id, &nonce, decision)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if let Some(handle) = self.structured.get(&id) {
|
||||||
|
handle
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("structured state mutex")
|
||||||
|
.approval_failed(&nonce, error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,13 +715,13 @@ impl App {
|
|||||||
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
|
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
|
||||||
// Escape hatch (#156): the literal byte, not the bar.
|
// Escape hatch (#156): the literal byte, not the bar.
|
||||||
Action::SendLiteralCtrlK => {
|
Action::SendLiteralCtrlK => {
|
||||||
effects.extend(self.forward_to_session(&InputKey::ctrl_char('k')));
|
effects.extend(self.forward_literal_to_terminal(&InputKey::ctrl_char('k')));
|
||||||
}
|
}
|
||||||
// Escape hatch (#158): the leader's own byte, built from the
|
// Escape hatch (#158): the leader's own byte, built from the
|
||||||
// configured leader rather than a hardcoded key.
|
// configured leader rather than a hardcoded key.
|
||||||
Action::SendLiteralLeader => {
|
Action::SendLiteralLeader => {
|
||||||
let leader = self.cfg.leader.clone();
|
let leader = self.cfg.leader.clone();
|
||||||
effects.extend(self.forward_to_session(&leader));
|
effects.extend(self.forward_literal_to_terminal(&leader));
|
||||||
}
|
}
|
||||||
Action::OpenSpawnModal => {
|
Action::OpenSpawnModal => {
|
||||||
self.open_spawn_modal();
|
self.open_spawn_modal();
|
||||||
@@ -587,7 +739,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
// A previewed session is already attached: committing
|
// A previewed session is already attached: committing
|
||||||
// to it keeps that stream rather than reopening one.
|
// to it keeps that stream rather than reopening one.
|
||||||
if self.emulators.get(sid).is_none() {
|
if !self.is_attached(sid) {
|
||||||
effects.push(Effect::Attach(sid));
|
effects.push(Effect::Attach(sid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,6 +747,9 @@ impl App {
|
|||||||
// preview budget.
|
// preview budget.
|
||||||
self.preview_lru.retain(|s| *s != sid);
|
self.preview_lru.retain(|s| *s != sid);
|
||||||
self.state.attach_to_focused(sid);
|
self.state.attach_to_focused(sid);
|
||||||
|
if self.session_view(sid) == SessionView::Structured {
|
||||||
|
self.state.mode = Mode::Normal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::KillHighlighted => {
|
Action::KillHighlighted => {
|
||||||
@@ -663,7 +818,7 @@ impl App {
|
|||||||
self.state.preview = Some(sid);
|
self.state.preview = Some(sid);
|
||||||
// Set outside the input path, so nothing else marks the frame stale.
|
// Set outside the input path, so nothing else marks the frame stale.
|
||||||
self.epoch = self.epoch.wrapping_add(1);
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
let fresh = self.emulators.get(sid).is_none();
|
let fresh = !self.is_attached(sid);
|
||||||
if fresh {
|
if fresh {
|
||||||
self.preview_lru.retain(|s| *s != sid);
|
self.preview_lru.retain(|s| *s != sid);
|
||||||
self.preview_lru.push(sid);
|
self.preview_lru.push(sid);
|
||||||
@@ -732,15 +887,27 @@ impl App {
|
|||||||
|
|
||||||
/// Open a stream for `sid` if one isn't already running.
|
/// Open a stream for `sid` if one isn't already running.
|
||||||
fn attach(&mut self, sid: Uuid) {
|
fn attach(&mut self, sid: Uuid) {
|
||||||
if self.emulators.get(sid).is_some() {
|
self.attach_with_view(sid, self.session_view(sid));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach_with_view(&mut self, sid: Uuid, view: SessionView) {
|
||||||
|
if self.is_attached(sid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match self.daemon.attach_url(sid) {
|
match view {
|
||||||
Ok(ws_url) => {
|
SessionView::Terminal => match self.daemon.attach_url(sid) {
|
||||||
let handle = SessionTask::spawn(ws_url, 24, 80);
|
Ok(ws_url) => {
|
||||||
self.emulators.insert(sid, handle);
|
let handle = SessionTask::spawn(ws_url, 24, 80);
|
||||||
}
|
self.emulators.insert(sid, handle);
|
||||||
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
}
|
||||||
|
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
||||||
|
},
|
||||||
|
SessionView::Structured => match self.daemon.structured_url(sid) {
|
||||||
|
Ok(ws_url) => {
|
||||||
|
self.structured.insert(sid, StructuredTask::spawn(ws_url));
|
||||||
|
}
|
||||||
|
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,9 +915,26 @@ impl App {
|
|||||||
/// dropping its vt100 parser.
|
/// dropping its vt100 parser.
|
||||||
fn release_session(&mut self, sid: Uuid) {
|
fn release_session(&mut self, sid: Uuid) {
|
||||||
self.emulators.remove(sid);
|
self.emulators.remove(sid);
|
||||||
|
if let Some(handle) = self.structured.remove(&sid) {
|
||||||
|
handle.end();
|
||||||
|
}
|
||||||
self.last_sent_size.remove(&sid);
|
self.last_sent_size.remove(&sid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn session_view(&self, sid: Uuid) -> SessionView {
|
||||||
|
self.catalog
|
||||||
|
.snapshot()
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|session| session.id == sid)
|
||||||
|
.map(|session| session.view)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_attached(&self, sid: Uuid) -> bool {
|
||||||
|
self.emulators.get(sid).is_some() || self.structured.contains_key(&sid)
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the session under the sidebar highlight, falling back to the
|
/// Return the session under the sidebar highlight, falling back to the
|
||||||
/// first listed session so the user can attach without arrowing first.
|
/// first listed session so the user can attach without arrowing first.
|
||||||
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
||||||
@@ -817,6 +1001,16 @@ impl App {
|
|||||||
/// attached to send it to.
|
/// attached to send it to.
|
||||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
let sid = self.state.active_session()?;
|
let sid = self.state.active_session()?;
|
||||||
|
if let Some(handle) = self.structured.get(&sid) {
|
||||||
|
let mut state = handle.state.lock().expect("structured state mutex");
|
||||||
|
if matches!(key, InputKey::Enter) {
|
||||||
|
return state
|
||||||
|
.begin_prompt()
|
||||||
|
.map(|prompt| Effect::StructuredPrompt { id: sid, prompt });
|
||||||
|
}
|
||||||
|
state.edit_composer(key);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let handle = self.emulators.get(sid)?;
|
let handle = self.emulators.get(sid)?;
|
||||||
let bytes = encode_key(key)?;
|
let bytes = encode_key(key)?;
|
||||||
handle
|
handle
|
||||||
@@ -827,6 +1021,86 @@ impl App {
|
|||||||
Some(Effect::Forward { id: sid, bytes })
|
Some(Effect::Forward { id: sid, bytes })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn forward_literal_to_terminal(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
|
let sid = self.state.active_session()?;
|
||||||
|
if self.session_view(sid) == SessionView::Structured {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.forward_to_session(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_structured_key(&mut self, key: &InputKey) -> Option<Vec<Effect>> {
|
||||||
|
if self.state.focus != Focus::Pane || self.state.preview.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sid = self.state.active_session()?;
|
||||||
|
let handle = self.structured.get(&sid)?;
|
||||||
|
let mut state = handle.state.lock().expect("structured state mutex");
|
||||||
|
if self.state.mode == Mode::Insert {
|
||||||
|
if matches!(key, InputKey::Esc) && state.turn_active() {
|
||||||
|
return Some(vec![Effect::StructuredCancel { id: sid }]);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let effects = match key {
|
||||||
|
InputKey::Char {
|
||||||
|
c: 'j',
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| InputKey::Down => {
|
||||||
|
state.select(1);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
InputKey::Char {
|
||||||
|
c: 'k',
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| InputKey::Up => {
|
||||||
|
state.select(-1);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
InputKey::Enter => {
|
||||||
|
state.toggle_selected();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
InputKey::Esc => {
|
||||||
|
if state.turn_active() {
|
||||||
|
vec![Effect::StructuredCancel { id: sid }]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputKey::Char {
|
||||||
|
c: key @ ('a' | 'A' | 'd'),
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
..
|
||||||
|
} => state
|
||||||
|
.approval_decision(*key)
|
||||||
|
.map(|(nonce, decision)| Effect::StructuredApproval {
|
||||||
|
id: sid,
|
||||||
|
nonce,
|
||||||
|
decision,
|
||||||
|
})
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
InputKey::PageUp => {
|
||||||
|
state.scroll_by(self.pane_page_rows() as isize);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
InputKey::PageDown => {
|
||||||
|
state.scroll_by(-(self.pane_page_rows() as isize));
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(effects)
|
||||||
|
}
|
||||||
|
|
||||||
fn pane_page_rows(&self) -> usize {
|
fn pane_page_rows(&self) -> usize {
|
||||||
self.region
|
self.region
|
||||||
.pane
|
.pane
|
||||||
@@ -839,6 +1113,14 @@ impl App {
|
|||||||
let Some(sid) = self.state.active_session() else {
|
let Some(sid) = self.state.active_session() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if let Some(handle) = self.structured.get(&sid) {
|
||||||
|
handle
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("structured state mutex")
|
||||||
|
.scroll_by(rows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(handle) = self.emulators.get(sid) else {
|
let Some(handle) = self.emulators.get(sid) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -872,6 +1154,14 @@ impl App {
|
|||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
let sid = self.state.active_session()?;
|
let sid = self.state.active_session()?;
|
||||||
|
if let Some(handle) = self.structured.get(&sid) {
|
||||||
|
handle
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("structured state mutex")
|
||||||
|
.scroll_by(lines);
|
||||||
|
return Some(None);
|
||||||
|
}
|
||||||
let handle = self.emulators.get(sid)?;
|
let handle = self.emulators.get(sid)?;
|
||||||
let pane = self.region.pane?;
|
let pane = self.region.pane?;
|
||||||
let parser = handle.screen.lock().expect("vt100 parser mutex");
|
let parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||||
@@ -1294,7 +1584,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.draft = None;
|
self.draft = None;
|
||||||
self.state.complete_spawn(sid);
|
self.state.complete_spawn(sid);
|
||||||
self.attach(sid);
|
if session.view == SessionView::Structured {
|
||||||
|
self.state.mode = Mode::Normal;
|
||||||
|
}
|
||||||
|
self.attach_with_view(sid, session.view);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// A failed POST belongs in the form it came from (#64), not a
|
// A failed POST belongs in the form it came from (#64), not a
|
||||||
@@ -1355,6 +1648,11 @@ impl App {
|
|||||||
self.emulators
|
self.emulators
|
||||||
.get(sid)
|
.get(sid)
|
||||||
.map(|h| (sid, h.revision(), h.state()))
|
.map(|h| (sid, h.revision(), h.state()))
|
||||||
|
.or_else(|| {
|
||||||
|
self.structured
|
||||||
|
.get(&sid)
|
||||||
|
.map(|h| (sid, h.revision(), h.connection()))
|
||||||
|
})
|
||||||
});
|
});
|
||||||
FrameKey {
|
FrameKey {
|
||||||
catalog: self.catalog.version(),
|
catalog: self.catalog.version(),
|
||||||
@@ -1416,6 +1714,17 @@ impl App {
|
|||||||
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
let structured_pane = shown_sid.and_then(|sid| {
|
||||||
|
let handle = self.structured.get(&sid)?;
|
||||||
|
let session = snap.sessions.iter().find(|session| session.id == sid)?;
|
||||||
|
Some(StructuredPane {
|
||||||
|
handle: handle.clone(),
|
||||||
|
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
||||||
|
mode: self.state.mode,
|
||||||
|
session_status: session.status,
|
||||||
|
activity_detail: session.activity_detail.clone(),
|
||||||
|
})
|
||||||
|
});
|
||||||
let pane_title = match shown_sid {
|
let pane_title = match shown_sid {
|
||||||
Some(sid) => {
|
Some(sid) => {
|
||||||
let name = snap
|
let name = snap
|
||||||
@@ -1439,6 +1748,7 @@ impl App {
|
|||||||
home_dir: &self.home_dir,
|
home_dir: &self.home_dir,
|
||||||
pane_title: &pane_title,
|
pane_title: &pane_title,
|
||||||
pane: pane.as_ref(),
|
pane: pane.as_ref(),
|
||||||
|
structured_pane: structured_pane.as_ref(),
|
||||||
state: &self.state,
|
state: &self.state,
|
||||||
draft: self.draft.as_ref(),
|
draft: self.draft.as_ref(),
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
@@ -1685,6 +1995,12 @@ async fn fetch_branches(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clipboard_payload_uses_standard_base64() {
|
||||||
|
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
|
||||||
|
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
|
||||||
|
}
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::draft::DraftField;
|
use crate::draft::DraftField;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
@@ -1900,6 +2216,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
@@ -2083,6 +2401,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
@@ -2112,6 +2432,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2789,6 +3111,7 @@ mod tests {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
app.submit_spawn(req).await;
|
app.submit_spawn(req).await;
|
||||||
|
|
||||||
@@ -2805,6 +3128,56 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_response_selects_transport_before_catalog_event() {
|
||||||
|
use axum::routing::post;
|
||||||
|
use axum::Json;
|
||||||
|
|
||||||
|
async fn spawn_session(
|
||||||
|
Json(req): Json<CreateSessionRequest>,
|
||||||
|
) -> Json<maestro_protocol::Session> {
|
||||||
|
let id = match req.view {
|
||||||
|
SessionView::Terminal => Uuid::from_u128(187),
|
||||||
|
SessionView::Structured => Uuid::from_u128(188),
|
||||||
|
};
|
||||||
|
let mut session = test_session(id, req.cwd.to_str().unwrap());
|
||||||
|
session.view = req.view;
|
||||||
|
session.command = req.command;
|
||||||
|
Json(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
let router =
|
||||||
|
axum::Router::new().route(maestro_protocol::routes::SESSIONS, post(spawn_session));
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
|
||||||
|
|
||||||
|
let mut app = make_app_at(&base);
|
||||||
|
let request = |view| CreateSessionRequest {
|
||||||
|
view,
|
||||||
|
cwd: std::path::PathBuf::from("/srv/work"),
|
||||||
|
command: "codex".into(),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata: Default::default(),
|
||||||
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let structured_id = Uuid::from_u128(188);
|
||||||
|
app.state.mode = Mode::Insert;
|
||||||
|
app.submit_spawn(request(SessionView::Structured)).await;
|
||||||
|
assert!(app.catalog.snapshot().sessions.is_empty());
|
||||||
|
assert!(app.structured.contains_key(&structured_id));
|
||||||
|
assert!(app.emulators.get(structured_id).is_none());
|
||||||
|
assert_eq!(app.state.mode, Mode::Normal);
|
||||||
|
|
||||||
|
let terminal_id = Uuid::from_u128(187);
|
||||||
|
app.submit_spawn(request(SessionView::Terminal)).await;
|
||||||
|
assert!(app.catalog.snapshot().sessions.is_empty());
|
||||||
|
assert!(app.emulators.get(terminal_id).is_some());
|
||||||
|
assert!(!app.structured.contains_key(&terminal_id));
|
||||||
|
}
|
||||||
|
|
||||||
/// The full data load against a live daemon stub: presets replace the
|
/// The full data load against a live daemon stub: presets replace the
|
||||||
/// built-ins, projects land sorted, and the first generated name seeds
|
/// built-ins, projects land sorted, and the first generated name seeds
|
||||||
/// the field.
|
/// the field.
|
||||||
@@ -3223,6 +3596,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: focused,
|
last_focused_at: focused,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
};
|
};
|
||||||
let older = Utc::now() - chrono::Duration::seconds(60);
|
let older = Utc::now() - chrono::Duration::seconds(60);
|
||||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
@@ -3503,6 +3878,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::KillHighlighted);
|
app.dispatch(Action::KillHighlighted);
|
||||||
@@ -3540,6 +3917,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3697,4 +4076,112 @@ mod tests {
|
|||||||
assert!(app.command_bar.is_none());
|
assert!(app.command_bar.is_none());
|
||||||
assert!(app.state.overlay().is_none());
|
assert!(app.state.overlay().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn structured_app(sid: Uuid) -> App {
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
let mut session = app.catalog.snapshot().sessions[0].clone();
|
||||||
|
session.view = SessionView::Structured;
|
||||||
|
app.catalog
|
||||||
|
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
|
||||||
|
app.structured.insert(
|
||||||
|
sid,
|
||||||
|
StructuredHandle::test_handle(crate::structured_task::StructuredState::default()),
|
||||||
|
);
|
||||||
|
app.state.attach_to_focused(sid);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn session_view_selects_the_structured_connection_and_cleanup_stops_it() {
|
||||||
|
let sid = Uuid::from_u128(141);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
let mut session = app.catalog.snapshot().sessions[0].clone();
|
||||||
|
session.view = SessionView::Structured;
|
||||||
|
app.catalog
|
||||||
|
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
|
||||||
|
|
||||||
|
app.attach(sid);
|
||||||
|
assert!(app.structured.contains_key(&sid));
|
||||||
|
assert!(app.emulators.get(sid).is_none());
|
||||||
|
let handle = app.structured.get(&sid).unwrap().clone();
|
||||||
|
app.release_session(sid);
|
||||||
|
assert_eq!(
|
||||||
|
handle.connection(),
|
||||||
|
crate::session_task::SessionState::Ended
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_normal_mode_routes_navigation_expansion_and_approval_by_row() {
|
||||||
|
use maestro_protocol::{ReducedState, TranscriptRow, TranscriptRowKind};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
let sid = Uuid::from_u128(141);
|
||||||
|
let mut app = structured_app(sid);
|
||||||
|
app.dispatch(Action::AttachHighlighted);
|
||||||
|
assert_eq!(app.state.focus, Focus::Pane);
|
||||||
|
assert_eq!(app.state.mode, Mode::Normal);
|
||||||
|
let handle = app.structured.get(&sid).unwrap().clone();
|
||||||
|
{
|
||||||
|
let mut state = handle.state.lock().unwrap();
|
||||||
|
state.rows = vec![
|
||||||
|
TranscriptRow {
|
||||||
|
id: "text".into(),
|
||||||
|
kind: TranscriptRowKind::AgentText,
|
||||||
|
data: json!({"content":{"type":"text","text":"hello"}}),
|
||||||
|
},
|
||||||
|
TranscriptRow {
|
||||||
|
id: "approval".into(),
|
||||||
|
kind: TranscriptRowKind::Approval,
|
||||||
|
data: json!({"options":[{"kind":"allow_once"}]}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.selected = Some("text".into());
|
||||||
|
state.reduced = ReducedState {
|
||||||
|
pending_approval: Some(json!({"nonce":"n"})),
|
||||||
|
..ReducedState::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.handle_structured_key(&InputKey::char('j')),
|
||||||
|
Some(vec![])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.handle_structured_key(&InputKey::char('a')),
|
||||||
|
Some(vec![Effect::StructuredApproval {
|
||||||
|
id: sid,
|
||||||
|
nonce: "n".into(),
|
||||||
|
decision: StructuredApprovalDecision::AllowOnce,
|
||||||
|
}])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
handle.state.lock().unwrap().selected.as_deref(),
|
||||||
|
Some("approval")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_insert_mode_edits_submits_and_cancels_without_pty_bytes() {
|
||||||
|
let sid = Uuid::from_u128(141);
|
||||||
|
let mut app = structured_app(sid);
|
||||||
|
assert!(app.emulators.get(sid).is_none());
|
||||||
|
app.forward_to_session(&InputKey::char('h'));
|
||||||
|
app.forward_to_session(&InputKey::char('i'));
|
||||||
|
assert_eq!(
|
||||||
|
app.forward_to_session(&InputKey::Enter),
|
||||||
|
Some(Effect::StructuredPrompt {
|
||||||
|
id: sid,
|
||||||
|
prompt: "hi".into(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.handle_structured_key(&InputKey::Esc),
|
||||||
|
Some(vec![Effect::StructuredCancel { id: sid }])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.forward_literal_to_terminal(&app.cfg.leader.clone()),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -811,6 +811,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+382
-13
@@ -41,8 +41,10 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_config::{builtin_presets, custom_preset};
|
use maestro_config::{builtin_presets, custom_preset};
|
||||||
use maestro_protocol::display::abbreviate_dir;
|
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
use maestro_protocol::{
|
||||||
|
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||||
|
};
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
@@ -86,9 +88,11 @@ pub enum DraftField {
|
|||||||
/// apply immediately, mirroring the web form's per-click choice.
|
/// apply immediately, mirroring the web form's per-click choice.
|
||||||
WorktreeChoice,
|
WorktreeChoice,
|
||||||
Name,
|
Name,
|
||||||
/// Disclosure collapsing the worktree/branch controls below (#93).
|
/// Disclosure collapsing the optional view and worktree controls below.
|
||||||
/// Closed by default; Enter / Space toggles.
|
/// Closed by default; Enter / Space toggles.
|
||||||
Advanced,
|
Advanced,
|
||||||
|
/// ACP-backed transcript instead of a terminal. Claude Code or Codex.
|
||||||
|
StructuredToggle,
|
||||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||||
WorktreeToggle,
|
WorktreeToggle,
|
||||||
/// New-vs-existing branch source; Left / Right switches.
|
/// New-vs-existing branch source; Left / Right switches.
|
||||||
@@ -251,6 +255,14 @@ pub struct DraftPane {
|
|||||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||||
/// take the default target.
|
/// take the default target.
|
||||||
pub advanced_collapsed: bool,
|
pub advanced_collapsed: bool,
|
||||||
|
/// ACP-backed structured transcript. Supported only when the resolved
|
||||||
|
/// command is Claude Code or Codex, and on by default whenever it is —
|
||||||
|
/// until the operator works the toggle themselves.
|
||||||
|
pub structured_view: bool,
|
||||||
|
/// Set once the operator toggles structured view by hand. From then on a
|
||||||
|
/// command change never re-enables what they turned off, the same guard
|
||||||
|
/// `name_touched` gives the name field.
|
||||||
|
pub structured_view_touched: bool,
|
||||||
/// Enclosing repository of the chosen target — from the project scan for
|
/// Enclosing repository of the chosen target — from the project scan for
|
||||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||||
pub base_repo: Option<PathBuf>,
|
pub base_repo: Option<PathBuf>,
|
||||||
@@ -291,7 +303,7 @@ impl Default for DraftPane {
|
|||||||
// while its data loads, and stay usable when the daemon reports none.
|
// while its data loads, and stay usable when the daemon reports none.
|
||||||
let mut presets = builtin_presets();
|
let mut presets = builtin_presets();
|
||||||
presets.push(custom_preset());
|
presets.push(custom_preset());
|
||||||
Self {
|
let mut draft = Self {
|
||||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
presets,
|
presets,
|
||||||
selected_preset: 0,
|
selected_preset: 0,
|
||||||
@@ -311,6 +323,8 @@ impl Default for DraftPane {
|
|||||||
name_touched: false,
|
name_touched: false,
|
||||||
worktree_enabled: true,
|
worktree_enabled: true,
|
||||||
advanced_collapsed: true,
|
advanced_collapsed: true,
|
||||||
|
structured_view: false,
|
||||||
|
structured_view_touched: false,
|
||||||
base_repo: None,
|
base_repo: None,
|
||||||
probed_cwd: None,
|
probed_cwd: None,
|
||||||
probe_seq: 0,
|
probe_seq: 0,
|
||||||
@@ -329,7 +343,11 @@ impl Default for DraftPane {
|
|||||||
// straight away, and Tab from here reaches the command tiles.
|
// straight away, and Tab from here reaches the command tiles.
|
||||||
focused_field: DraftField::DirPicker,
|
focused_field: DraftField::DirPicker,
|
||||||
home_dir: String::new(),
|
home_dir: String::new(),
|
||||||
}
|
};
|
||||||
|
// The built-in tile the form opens on decides whether structured view
|
||||||
|
// starts ticked, exactly as a later preset list would.
|
||||||
|
draft.sync_structured_view();
|
||||||
|
draft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +366,7 @@ impl DraftPane {
|
|||||||
.position(|p| !p.command.is_empty())
|
.position(|p| !p.command.is_empty())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
self.apply_seed_command();
|
self.apply_seed_command();
|
||||||
|
self.sync_structured_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||||
@@ -663,6 +682,28 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn supports_structured_view(&self) -> bool {
|
||||||
|
supports_structured_view(self.command())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-resolve the toggle after the command changed: an unsupported
|
||||||
|
/// command forces it off, a supported one turns it back on unless the
|
||||||
|
/// operator has already made the choice themselves.
|
||||||
|
fn sync_structured_view(&mut self) {
|
||||||
|
if !self.supports_structured_view() {
|
||||||
|
self.structured_view = false;
|
||||||
|
} else if !self.structured_view_touched {
|
||||||
|
self.structured_view = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_structured_view(&mut self) {
|
||||||
|
if self.supports_structured_view() {
|
||||||
|
self.structured_view = !self.structured_view;
|
||||||
|
self.structured_view_touched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The chosen working directory: a picked project row, else the raw path
|
/// The chosen working directory: a picked project row, else the raw path
|
||||||
/// input when it holds something.
|
/// input when it holds something.
|
||||||
pub fn cwd(&self) -> Option<PathBuf> {
|
pub fn cwd(&self) -> Option<PathBuf> {
|
||||||
@@ -693,13 +734,22 @@ impl DraftPane {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let worktree = self.build_worktree()?;
|
let worktree = self.build_worktree()?;
|
||||||
|
let auto_approve = self
|
||||||
|
.presets
|
||||||
|
.get(self.selected_preset)
|
||||||
|
.is_some_and(|p| p.auto_approve);
|
||||||
Ok(CreateSessionRequest {
|
Ok(CreateSessionRequest {
|
||||||
view: Default::default(),
|
view: if self.structured_view && self.supports_structured_view() {
|
||||||
|
SessionView::Structured
|
||||||
|
} else {
|
||||||
|
SessionView::Terminal
|
||||||
|
},
|
||||||
cwd,
|
cwd,
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
|
auto_approve,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,6 +804,9 @@ impl DraftPane {
|
|||||||
order.push(DraftField::Name);
|
order.push(DraftField::Name);
|
||||||
order.push(DraftField::Advanced);
|
order.push(DraftField::Advanced);
|
||||||
if !self.advanced_collapsed {
|
if !self.advanced_collapsed {
|
||||||
|
if self.supports_structured_view() {
|
||||||
|
order.push(DraftField::StructuredToggle);
|
||||||
|
}
|
||||||
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
||||||
order.push(DraftField::WorktreeChoice);
|
order.push(DraftField::WorktreeChoice);
|
||||||
}
|
}
|
||||||
@@ -795,6 +848,7 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
self.selected_preset =
|
self.selected_preset =
|
||||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||||
|
self.sync_structured_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||||
@@ -1001,9 +1055,9 @@ impl DraftPane {
|
|||||||
// Two fields keep their own Enter instead: `DirPicker`, whose
|
// Two fields keep their own Enter instead: `DirPicker`, whose
|
||||||
// highlighted row isn't the picked one until a first Enter chooses
|
// highlighted row isn't the picked one until a first Enter chooses
|
||||||
// it (the next Enter, now that it's active, falls through to
|
// it (the next Enter, now that it's active, falls through to
|
||||||
// submit); and the disclosure/toggle pair (`Advanced`,
|
// submit); and the disclosure/checkbox fields, which activate on
|
||||||
// `WorktreeToggle`), which activate on Enter like a button rather
|
// Enter like buttons rather than submitting, matching the web
|
||||||
// than submitting, same as the web form's own `<button>` controls.
|
// form controls.
|
||||||
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
||||||
let is_other_row = self.selection() >= self.visible_groups().len();
|
let is_other_row = self.selection() >= self.visible_groups().len();
|
||||||
let choose_only =
|
let choose_only =
|
||||||
@@ -1023,6 +1077,10 @@ impl DraftPane {
|
|||||||
self.toggle_worktree();
|
self.toggle_worktree();
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
}
|
}
|
||||||
|
InputKey::Enter if self.focused_field == DraftField::StructuredToggle => {
|
||||||
|
self.toggle_structured_view();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
InputKey::Enter => DraftInputResult::Submit,
|
InputKey::Enter => DraftInputResult::Submit,
|
||||||
InputKey::Char {
|
InputKey::Char {
|
||||||
c: ' ',
|
c: ' ',
|
||||||
@@ -1040,6 +1098,14 @@ impl DraftPane {
|
|||||||
self.toggle_worktree();
|
self.toggle_worktree();
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
}
|
}
|
||||||
|
InputKey::Char {
|
||||||
|
c: ' ',
|
||||||
|
ctrl: false,
|
||||||
|
..
|
||||||
|
} if self.focused_field == DraftField::StructuredToggle => {
|
||||||
|
self.toggle_structured_view();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
||||||
self.move_selection(-1);
|
self.move_selection(-1);
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
@@ -1083,7 +1149,10 @@ impl DraftPane {
|
|||||||
other => {
|
other => {
|
||||||
match self.focused_field {
|
match self.focused_field {
|
||||||
DraftField::Preset | DraftField::Submit => {}
|
DraftField::Preset | DraftField::Submit => {}
|
||||||
DraftField::CustomCommand => self.custom_command.handle_key(other),
|
DraftField::CustomCommand => {
|
||||||
|
self.custom_command.handle_key(other);
|
||||||
|
self.sync_structured_view();
|
||||||
|
}
|
||||||
// Typing filters the list; the highlight resets to the top
|
// Typing filters the list; the highlight resets to the top
|
||||||
// so a narrowed search always starts at its best match.
|
// so a narrowed search always starts at its best match.
|
||||||
DraftField::DirPicker => {
|
DraftField::DirPicker => {
|
||||||
@@ -1121,9 +1190,9 @@ impl DraftPane {
|
|||||||
|
|
||||||
let show_custom = self.is_custom();
|
let show_custom = self.is_custom();
|
||||||
let show_raw = self.cwd_raw_visible;
|
let show_raw = self.cwd_raw_visible;
|
||||||
// The worktree choice step, the worktree toggle and its body all stay
|
let show_structured = !self.advanced_collapsed && self.supports_structured_view();
|
||||||
// behind the "Advanced options" disclosure (#93): collapsed hides
|
// Optional view and worktree controls stay behind the "Advanced
|
||||||
// them, regardless of what they'd otherwise show.
|
// options" disclosure (#93), regardless of what they'd otherwise show.
|
||||||
let show_wt_toggle = !self.advanced_collapsed;
|
let show_wt_toggle = !self.advanced_collapsed;
|
||||||
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
||||||
let show_wt_choice =
|
let show_wt_choice =
|
||||||
@@ -1142,6 +1211,7 @@ impl DraftPane {
|
|||||||
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
||||||
+ u16::from(show_custom)
|
+ u16::from(show_custom)
|
||||||
+ u16::from(show_raw)
|
+ u16::from(show_raw)
|
||||||
|
+ u16::from(show_structured)
|
||||||
+ u16::from(show_wt_toggle)
|
+ u16::from(show_wt_toggle)
|
||||||
+ u16::from(show_wt_choice) * wt_choice_capacity
|
+ u16::from(show_wt_choice) * wt_choice_capacity
|
||||||
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
||||||
@@ -1161,6 +1231,9 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
constraints.push(Constraint::Length(1)); // name
|
constraints.push(Constraint::Length(1)); // name
|
||||||
constraints.push(Constraint::Length(1)); // advanced header
|
constraints.push(Constraint::Length(1)); // advanced header
|
||||||
|
if show_structured {
|
||||||
|
constraints.push(Constraint::Length(1));
|
||||||
|
}
|
||||||
if show_wt_choice {
|
if show_wt_choice {
|
||||||
constraints.push(Constraint::Length(wt_choice_capacity));
|
constraints.push(Constraint::Length(wt_choice_capacity));
|
||||||
}
|
}
|
||||||
@@ -1359,6 +1432,24 @@ impl DraftPane {
|
|||||||
advanced_row,
|
advanced_row,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if show_structured {
|
||||||
|
let row = next_row();
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw(format!("{:<GUTTER$}", "view:")),
|
||||||
|
Span::styled(
|
||||||
|
if self.structured_view {
|
||||||
|
"[x] structured view"
|
||||||
|
} else {
|
||||||
|
"[ ] structured view"
|
||||||
|
},
|
||||||
|
field_style(DraftField::StructuredToggle),
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
row,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if show_wt_choice {
|
if show_wt_choice {
|
||||||
// Step two (#96 rule 3): "+ new" first, then the repository's
|
// Step two (#96 rule 3): "+ new" first, then the repository's
|
||||||
// worktrees newest-first.
|
// worktrees newest-first.
|
||||||
@@ -1706,6 +1797,7 @@ mod tests {
|
|||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]);
|
}]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.command(),
|
draft.command(),
|
||||||
@@ -1728,11 +1820,13 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(draft.command(), "aider");
|
assert_eq!(draft.command(), "aider");
|
||||||
@@ -1752,6 +1846,7 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]);
|
}]);
|
||||||
assert!(draft.is_custom());
|
assert!(draft.is_custom());
|
||||||
assert_eq!(draft.command(), "bash -l");
|
assert_eq!(draft.command(), "bash -l");
|
||||||
@@ -1817,6 +1912,280 @@ mod tests {
|
|||||||
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_view_is_default_on_and_visible_for_supported_agents() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
advanced_collapsed: false,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_presets(vec![
|
||||||
|
SpawnPreset {
|
||||||
|
id: "claude".into(),
|
||||||
|
label: "Claude".into(),
|
||||||
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
|
},
|
||||||
|
SpawnPreset {
|
||||||
|
id: "codex".into(),
|
||||||
|
label: "Codex".into(),
|
||||||
|
command: "codex".into(),
|
||||||
|
auto_approve: false,
|
||||||
|
},
|
||||||
|
SpawnPreset {
|
||||||
|
id: "opencode".into(),
|
||||||
|
label: "OpenCode".into(),
|
||||||
|
command: "opencode".into(),
|
||||||
|
auto_approve: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert!(draft.structured_view, "on by default for a supported agent");
|
||||||
|
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
|
assert!(rendered.contains("[x] structured view"), "{rendered}");
|
||||||
|
|
||||||
|
draft.cycle_preset(1);
|
||||||
|
assert_eq!(draft.command(), "codex");
|
||||||
|
assert!(
|
||||||
|
draft.structured_view,
|
||||||
|
"Codex keeps the structured selection"
|
||||||
|
);
|
||||||
|
draft.cycle_preset(1);
|
||||||
|
assert_eq!(draft.command(), "opencode");
|
||||||
|
assert!(
|
||||||
|
!draft.structured_view,
|
||||||
|
"a hidden value must not stay checked"
|
||||||
|
);
|
||||||
|
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
|
assert!(!rendered.contains("structured view"), "{rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_view_request_is_guarded_against_a_stale_command() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||||
|
worktree_enabled: false,
|
||||||
|
structured_view: true,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||||
|
|
||||||
|
draft.selected_preset = 1;
|
||||||
|
assert_eq!(draft.command(), "opencode");
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A custom command typed into the free-text field, run through the same
|
||||||
|
/// resolver the daemon/web gate uses (`command_tool`, #199): a path, an
|
||||||
|
/// env prefix, or a wrapper still resolves to Claude Code or Codex, so
|
||||||
|
/// the toggle stays offered and the request carries `Structured`; an
|
||||||
|
/// unrelated program does not.
|
||||||
|
#[test]
|
||||||
|
fn structured_view_follows_command_resolution_for_custom_commands() {
|
||||||
|
let cases: &[(&str, bool)] = &[
|
||||||
|
("claude", true),
|
||||||
|
("claude --resume", true),
|
||||||
|
("CLAUDE", true),
|
||||||
|
("/usr/local/bin/claude -p hi", true),
|
||||||
|
("./bin/claude", true),
|
||||||
|
("FOO=1 BAR=2 claude", true),
|
||||||
|
("npx claude-code", true),
|
||||||
|
("npx --yes claude-code", true),
|
||||||
|
("sudo claude", true),
|
||||||
|
("sudo -u root claude", true),
|
||||||
|
("env -i claude", true),
|
||||||
|
("nohup claude", true),
|
||||||
|
("time claude", true),
|
||||||
|
("command claude", true),
|
||||||
|
("pnpm --silent dlx claude-code", true),
|
||||||
|
("yarn --silent dlx claude-code", true),
|
||||||
|
("bun x claude-code", true),
|
||||||
|
("\"/opt/my agent/claude\" --resume", true),
|
||||||
|
("codex", true),
|
||||||
|
("codex --full-auto", true),
|
||||||
|
("CODEX=1 codex", true),
|
||||||
|
("/usr/local/bin/codex exec", true),
|
||||||
|
("npx codex", true),
|
||||||
|
("sudo -u root codex", true),
|
||||||
|
("env -i codex", true),
|
||||||
|
("command codex", true),
|
||||||
|
("pnpm --silent dlx codex", true),
|
||||||
|
("\"/opt/my agent/codex\" exec", true),
|
||||||
|
("bash", false),
|
||||||
|
("opencode", false),
|
||||||
|
("/usr/local/bin/bash -l", false),
|
||||||
|
// A package-manager wrapper without `dlx`/`x` runs a
|
||||||
|
// `package.json` script, not the package (#199) — must not be
|
||||||
|
// mistaken for a Claude launch.
|
||||||
|
("pnpm claude", false),
|
||||||
|
("yarn claude", false),
|
||||||
|
// The daemon's structured-session child, never a valid
|
||||||
|
// user-facing launch form (#199).
|
||||||
|
("claude-code-acp", false),
|
||||||
|
];
|
||||||
|
for &(command, supported) in cases {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||||
|
worktree_enabled: false,
|
||||||
|
advanced_collapsed: false,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.selected_preset = draft.presets.len() - 1;
|
||||||
|
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
||||||
|
draft.sync_structured_view();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
draft.field_order().contains(&DraftField::StructuredToggle),
|
||||||
|
supported,
|
||||||
|
"toggle visibility for {command:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
draft.structured_view, supported,
|
||||||
|
"default selection for {command:?}"
|
||||||
|
);
|
||||||
|
let view = draft.build_request().unwrap().view;
|
||||||
|
assert_eq!(
|
||||||
|
view,
|
||||||
|
if supported {
|
||||||
|
SessionView::Structured
|
||||||
|
} else {
|
||||||
|
SessionView::Terminal
|
||||||
|
},
|
||||||
|
"request view for {command:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
draft.toggle_structured_view();
|
||||||
|
assert!(
|
||||||
|
!draft.structured_view,
|
||||||
|
"the toggle turns the default off for {command:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A configured preset (the spawn-form "alias" for a command) is gated
|
||||||
|
/// the same way as free text: a preset whose command resolves to Claude
|
||||||
|
/// Code or Codex through a wrapper still offers structured view.
|
||||||
|
#[test]
|
||||||
|
fn structured_view_follows_command_resolution_for_presets() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||||
|
worktree_enabled: false,
|
||||||
|
advanced_collapsed: false,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_presets(vec![
|
||||||
|
SpawnPreset {
|
||||||
|
id: "claude-wrapped".into(),
|
||||||
|
label: "Claude (npx)".into(),
|
||||||
|
command: "npx claude-code --resume".into(),
|
||||||
|
auto_approve: false,
|
||||||
|
},
|
||||||
|
SpawnPreset {
|
||||||
|
id: "codex-wrapped".into(),
|
||||||
|
label: "Codex (npx)".into(),
|
||||||
|
command: "npx codex exec".into(),
|
||||||
|
auto_approve: false,
|
||||||
|
},
|
||||||
|
SpawnPreset {
|
||||||
|
id: "aider".into(),
|
||||||
|
label: "Aider".into(),
|
||||||
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(draft.command(), "npx claude-code --resume");
|
||||||
|
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
|
assert!(draft.structured_view);
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||||
|
|
||||||
|
// Switching between supported presets preserves the selection.
|
||||||
|
draft.cycle_preset(1);
|
||||||
|
assert_eq!(draft.command(), "npx codex exec");
|
||||||
|
assert!(draft.structured_view);
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||||
|
|
||||||
|
// Switching to a preset that does not resolve to an ACP agent clears the
|
||||||
|
// stale selection instead of letting an invalid request through.
|
||||||
|
draft.cycle_preset(1);
|
||||||
|
assert_eq!(draft.command(), "aider");
|
||||||
|
assert!(!draft.structured_view, "stale selection must clear");
|
||||||
|
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default only applies until the operator has an opinion: once they
|
||||||
|
/// untick the box, moving between supported commands leaves it unticked
|
||||||
|
/// instead of turning it back on under them.
|
||||||
|
#[test]
|
||||||
|
fn an_unticked_structured_view_survives_a_command_change() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||||
|
worktree_enabled: false,
|
||||||
|
advanced_collapsed: false,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_presets(vec![
|
||||||
|
SpawnPreset {
|
||||||
|
id: "claude".into(),
|
||||||
|
label: "Claude".into(),
|
||||||
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
|
},
|
||||||
|
SpawnPreset {
|
||||||
|
id: "codex".into(),
|
||||||
|
label: "Codex".into(),
|
||||||
|
command: "codex".into(),
|
||||||
|
auto_approve: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert!(draft.structured_view);
|
||||||
|
|
||||||
|
draft.toggle_structured_view();
|
||||||
|
assert!(!draft.structured_view);
|
||||||
|
|
||||||
|
draft.cycle_preset(1);
|
||||||
|
assert_eq!(draft.command(), "codex");
|
||||||
|
assert!(
|
||||||
|
!draft.structured_view,
|
||||||
|
"the operator's choice outranks the default"
|
||||||
|
);
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Editing the custom-command text away from a supported agent clears an armed
|
||||||
|
/// selection immediately, matching the preset-switch case above — an
|
||||||
|
/// invalid `Structured` request can never reach `build_request`.
|
||||||
|
#[test]
|
||||||
|
fn structured_view_clears_when_custom_command_edited_away_from_supported_agent() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||||
|
worktree_enabled: false,
|
||||||
|
advanced_collapsed: false,
|
||||||
|
focused_field: DraftField::CustomCommand,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.selected_preset = draft.presets.len() - 1;
|
||||||
|
draft.custom_command = crate::text_input::TextInput::with_value("codex");
|
||||||
|
draft.sync_structured_view();
|
||||||
|
assert!(draft.structured_view);
|
||||||
|
|
||||||
|
typed(&mut draft, " -p");
|
||||||
|
assert_eq!(draft.command(), "codex -p");
|
||||||
|
assert!(draft.structured_view, "still Codex, selection survives");
|
||||||
|
|
||||||
|
for _ in 0.."codex -p".chars().count() {
|
||||||
|
draft.on_input(&InputKey::Backspace);
|
||||||
|
}
|
||||||
|
typed(&mut draft, "bash");
|
||||||
|
assert_eq!(draft.command(), "bash");
|
||||||
|
assert!(
|
||||||
|
!draft.structured_view,
|
||||||
|
"editing the program away from Codex clears the stale selection"
|
||||||
|
);
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ impl FastForwardDefaults {
|
|||||||
branch: handle.to_string(),
|
branch: handle.to_string(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +185,7 @@ mod tests {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
label: id.into(),
|
label: id.into(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ pub enum MouseKind {
|
|||||||
Click,
|
Click,
|
||||||
DblClick,
|
DblClick,
|
||||||
Drag,
|
Drag,
|
||||||
|
Hover,
|
||||||
ScrollUp,
|
ScrollUp,
|
||||||
ScrollDown,
|
ScrollDown,
|
||||||
}
|
}
|
||||||
@@ -148,6 +149,7 @@ impl InputKey {
|
|||||||
MouseKind::Click => "click",
|
MouseKind::Click => "click",
|
||||||
MouseKind::DblClick => "dblclick",
|
MouseKind::DblClick => "dblclick",
|
||||||
MouseKind::Drag => "drag",
|
MouseKind::Drag => "drag",
|
||||||
|
MouseKind::Hover => "hover",
|
||||||
MouseKind::ScrollUp => "scrollup",
|
MouseKind::ScrollUp => "scrollup",
|
||||||
MouseKind::ScrollDown => "scrolldown",
|
MouseKind::ScrollDown => "scrolldown",
|
||||||
};
|
};
|
||||||
@@ -178,6 +180,7 @@ impl InputKey {
|
|||||||
"click" => MouseKind::Click,
|
"click" => MouseKind::Click,
|
||||||
"dblclick" => MouseKind::DblClick,
|
"dblclick" => MouseKind::DblClick,
|
||||||
"drag" => MouseKind::Drag,
|
"drag" => MouseKind::Drag,
|
||||||
|
"hover" => MouseKind::Hover,
|
||||||
"scrollup" => MouseKind::ScrollUp,
|
"scrollup" => MouseKind::ScrollUp,
|
||||||
"scrolldown" => MouseKind::ScrollDown,
|
"scrolldown" => MouseKind::ScrollDown,
|
||||||
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
||||||
@@ -297,6 +300,7 @@ where
|
|||||||
let mk = match kind {
|
let mk = match kind {
|
||||||
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
||||||
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
||||||
|
MouseEventKind::Moved => MouseKind::Hover,
|
||||||
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
||||||
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
@@ -681,6 +685,9 @@ mod tests {
|
|||||||
let s = "mouse:dblclick(sidebar-row)";
|
let s = "mouse:dblclick(sidebar-row)";
|
||||||
let k = InputKey::parse(s).unwrap();
|
let k = InputKey::parse(s).unwrap();
|
||||||
assert_eq!(k.canonical(), s);
|
assert_eq!(k.canonical(), s);
|
||||||
|
let s = "mouse:hover(pane)";
|
||||||
|
let k = InputKey::parse(s).unwrap();
|
||||||
|
assert_eq!(k.canonical(), s);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ pub mod render;
|
|||||||
pub mod session_task;
|
pub mod session_task;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod structured_pane;
|
||||||
|
pub mod structured_task;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod text_input;
|
pub mod text_input;
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ impl Surface {
|
|||||||
pub fn activity(&self, state: ActivityState) -> Color {
|
pub fn activity(&self, state: ActivityState) -> Color {
|
||||||
match state {
|
match state {
|
||||||
ActivityState::Active => self.active,
|
ActivityState::Active => self.active,
|
||||||
ActivityState::Idle => self.idle,
|
ActivityState::Idle | ActivityState::Starting => self.idle,
|
||||||
ActivityState::AwaitingInput => self.awaiting,
|
ActivityState::AwaitingInput => self.awaiting,
|
||||||
ActivityState::Error => self.error,
|
ActivityState::Error => self.error,
|
||||||
}
|
}
|
||||||
@@ -928,9 +928,10 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const STATES: [ActivityState; 4] = [
|
const STATES: [ActivityState; 5] = [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
|
ActivityState::Starting,
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::palette;
|
|||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||||
|
use crate::structured_pane::StructuredPane;
|
||||||
|
|
||||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||||
/// coordinate to one of these via [`RegionMap::region_at`].
|
/// coordinate to one of these via [`RegionMap::region_at`].
|
||||||
@@ -80,6 +81,7 @@ pub struct RenderInputs<'a> {
|
|||||||
pub home_dir: &'a str,
|
pub home_dir: &'a str,
|
||||||
pub pane_title: &'a str,
|
pub pane_title: &'a str,
|
||||||
pub pane: Option<&'a Pane>,
|
pub pane: Option<&'a Pane>,
|
||||||
|
pub structured_pane: Option<&'a StructuredPane>,
|
||||||
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
|
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
|
||||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||||
pub state: &'a AppState,
|
pub state: &'a AppState,
|
||||||
@@ -147,14 +149,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
||||||
if let Some(p) = inputs.pane {
|
if let Some(p) = inputs.pane {
|
||||||
p.render(f, pane_area);
|
p.render(f, pane_area);
|
||||||
|
} else if let Some(p) = inputs.structured_pane {
|
||||||
|
p.render(f, pane_area);
|
||||||
}
|
}
|
||||||
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
||||||
// chrome; every remaining cell belongs to the emulator.
|
// chrome; every remaining cell belongs to the active session pane.
|
||||||
map.pane = Some(pane_area);
|
map.pane = Some(pane_area);
|
||||||
|
|
||||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||||
// always see the current mode + key hints.
|
// always see the current mode + key hints.
|
||||||
render_status_bar(f, status_area, inputs.state, inputs.link);
|
render_status_bar(
|
||||||
|
f,
|
||||||
|
status_area,
|
||||||
|
inputs.state,
|
||||||
|
inputs.link,
|
||||||
|
inputs.structured_pane.is_some(),
|
||||||
|
);
|
||||||
|
|
||||||
match inputs.state.overlay() {
|
match inputs.state.overlay() {
|
||||||
Some(OverlayKind::Spawn) => {
|
Some(OverlayKind::Spawn) => {
|
||||||
@@ -253,7 +263,13 @@ fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
|||||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||||
/// mode + focused panel + the most useful key hints so the user is never
|
/// mode + focused panel + the most useful key hints so the user is never
|
||||||
/// guessing what state they're in.
|
/// guessing what state they're in.
|
||||||
fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkStatus) {
|
fn render_status_bar(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
state: &AppState,
|
||||||
|
link: &LinkStatus,
|
||||||
|
structured: bool,
|
||||||
|
) {
|
||||||
// The daemon indicator is pinned right and sized to its own text, so a
|
// The daemon indicator is pinned right and sized to its own text, so a
|
||||||
// narrow terminal truncates the key hints rather than the release.
|
// narrow terminal truncates the key hints rather than the release.
|
||||||
let label = link_label(link);
|
let label = link_label(link);
|
||||||
@@ -271,9 +287,13 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkSta
|
|||||||
Focus::Sidebar => "sidebar",
|
Focus::Sidebar => "sidebar",
|
||||||
Focus::Pane => "pane",
|
Focus::Pane => "pane",
|
||||||
};
|
};
|
||||||
let hint = match state.mode {
|
let hint = match (structured && state.focus == Focus::Pane, state.mode) {
|
||||||
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
|
(true, Mode::Normal) => "j/k: rows • enter: expand • i: compose • esc: cancel",
|
||||||
Mode::Insert => "^a: return to sidebar",
|
(true, Mode::Insert) => "enter: send • esc: cancel • ^a: sidebar",
|
||||||
|
(false, Mode::Normal) => {
|
||||||
|
"j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit"
|
||||||
|
}
|
||||||
|
(false, Mode::Insert) => "^a: return to sidebar",
|
||||||
};
|
};
|
||||||
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||||
let line = ratatui::text::Line::from(vec![
|
let line = ratatui::text::Line::from(vec![
|
||||||
@@ -435,6 +455,7 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
|
structured_pane: None,
|
||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
@@ -532,6 +553,7 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
|
structured_pane: None,
|
||||||
draft: Some(&draft),
|
draft: Some(&draft),
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
@@ -587,6 +609,7 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
|
structured_pane: None,
|
||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
@@ -629,6 +652,7 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
|
structured_pane: None,
|
||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ impl SessionTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jitter(base: Duration) -> Duration {
|
pub(crate) fn jitter(base: Duration) -> Duration {
|
||||||
let ms = base.as_millis() as u64;
|
let ms = base.as_millis() as u64;
|
||||||
let now_nanos = std::time::SystemTime::now()
|
let now_nanos = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -502,6 +502,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,6 +981,7 @@ mod tests {
|
|||||||
for state in [
|
for state in [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
|
ActivityState::Starting,
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
] {
|
] {
|
||||||
@@ -1009,6 +1012,7 @@ mod tests {
|
|||||||
for state in [
|
for state in [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
|
ActivityState::Starting,
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
] {
|
] {
|
||||||
@@ -1270,6 +1274,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
};
|
};
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user